mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
3 Commits
v0.3.0
...
feat/langf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36bf127d9 | ||
|
|
30b598d960 | ||
|
|
d84edb529c |
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,4 +41,3 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
push-via-ec2.sh
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
@@ -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.
|
||||
190
LICENSE
190
LICENSE
@@ -1,190 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Dayuan Jiang
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
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
|
||||
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,12 +89,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
- OpenRouter
|
||||
- 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.
|
||||
|
||||
## 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_MODEL` to the specific model you want to use
|
||||
- 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 [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
||||
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
|
||||
|
||||
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(默认)
|
||||
- OpenAI
|
||||
- OpenAI / OpenAI兼容API(通过 `OPENAI_BASE_URL`)
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- OpenRouter
|
||||
- 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架构图,这是最佳选择。
|
||||
|
||||
## 快速开始
|
||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||
- 添加您的提供商所需的API密钥
|
||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
|
||||
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例。
|
||||
|
||||
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(デフォルト)
|
||||
- OpenAI
|
||||
- OpenAI / OpenAI互換API(`OPENAI_BASE_URL`経由)
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- OpenRouter
|
||||
- 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アーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
## はじめに
|
||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- `AI_MODEL`を使用する特定のモデルに設定
|
||||
- プロバイダーに必要なAPIキーを追加
|
||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||
|
||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||
|
||||
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
|
||||
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください。
|
||||
|
||||
4. 開発サーバーを起動:
|
||||
|
||||
|
||||
22
amplify.yml
22
amplify.yml
@@ -1,22 +0,0 @@
|
||||
version: 1
|
||||
frontend:
|
||||
phases:
|
||||
preBuild:
|
||||
commands:
|
||||
- npm ci --cache .npm --prefer-offline
|
||||
build:
|
||||
commands:
|
||||
# Write env vars to .env.production for Next.js SSR runtime
|
||||
- env | grep -e AI_MODEL >> .env.production
|
||||
- env | grep -e AI_PROVIDER >> .env.production
|
||||
- env | grep -e OPENAI_API_KEY >> .env.production
|
||||
- env | grep -e NEXT_PUBLIC_ >> .env.production
|
||||
- npm run build
|
||||
artifacts:
|
||||
baseDirectory: .next
|
||||
files:
|
||||
- '**/*'
|
||||
cache:
|
||||
paths:
|
||||
- .next/cache/**/*
|
||||
- .npm/**/*
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
description:
|
||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
}
|
||||
};
|
||||
|
||||
export default function AboutCN() {
|
||||
return (
|
||||
@@ -17,23 +16,14 @@ export default function AboutCN() {
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||
编辑器
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
<Link href="/about/cn" className="text-blue-600 font-semibold">
|
||||
关于
|
||||
</Link>
|
||||
<a
|
||||
@@ -55,41 +45,22 @@ export default function AboutCN() {
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<Link href="/about/cn" className="text-blue-600 font-semibold">中文</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
本应用设计运行于 Claude Opus 4.5
|
||||
以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至
|
||||
Claude Haiku 4.5。
|
||||
本应用设计运行于 Claude Opus 4.5 以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至 Claude Haiku 4.5。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -98,167 +69,80 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
功能特性
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">功能特性</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM驱动的图表创建</strong>
|
||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>基于图像的图表复制</strong>
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
:与AI实时对话来完善您的图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS架构图支持</strong>
|
||||
:专门支持生成AWS架构图
|
||||
</li>
|
||||
<li>
|
||||
<strong>动画连接器</strong>
|
||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
</li>
|
||||
<li><strong>LLM驱动的图表创建</strong>:利用大语言模型通过自然语言命令直接创建和操作draw.io图表</li>
|
||||
<li><strong>基于图像的图表复制</strong>:上传现有图表或图像,让AI自动复制和增强</li>
|
||||
<li><strong>图表历史记录</strong>:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本</li>
|
||||
<li><strong>交互式聊天界面</strong>:与AI实时对话来完善您的图表</li>
|
||||
<li><strong>AWS架构图支持</strong>:专门支持生成AWS架构图</li>
|
||||
<li><strong>动画连接器</strong>:在图表元素之间创建动态动画连接器,实现更好的可视化效果</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
示例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下是一些示例提示词及其生成的图表:
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">示例</h2>
|
||||
<p className="text-gray-700 mb-6">以下是一些示例提示词及其生成的图表:</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
动画Transformer连接器
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">动画Transformer连接器</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>提示词:</strong> 给我一个带有
|
||||
<strong>动画连接器</strong>的Transformer架构图。
|
||||
<strong>提示词:</strong> 给我一个带有<strong>动画连接器</strong>的Transformer架构图。
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="带动画连接器的Transformer架构"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/animated_connectors.svg" alt="带动画连接器的Transformer架构" width={480} height={360} className="mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP架构图
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP架构图</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>GCP图标</strong>
|
||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用<strong>GCP图标</strong>生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/gcp_demo.svg" alt="GCP架构图" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS架构图
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS架构图</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>AWS图标</strong>
|
||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用<strong>AWS图标</strong>生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/aws_demo.svg" alt="AWS架构图" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure架构图
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure架构图</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>Azure图标</strong>
|
||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用<strong>Azure图标</strong>生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/azure_demo.svg" alt="Azure架构图" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫咪素描
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫咪素描</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong>{" "}
|
||||
给我画一只可爱的猫。
|
||||
<strong>提示词:</strong> 给我画一只可爱的猫。
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫咪绘图"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/cat_demo.svg" alt="猫咪绘图" width={240} height={240} className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
工作原理
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">工作原理</h2>
|
||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>:用于前端框架和路由
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>:用于图表表示和操作
|
||||
</li>
|
||||
<li><strong>Next.js</strong>:用于前端框架和路由</li>
|
||||
<li><strong>Vercel AI SDK</strong>(<code>ai</code> + <code>@ai-sdk/*</code>):用于流式AI响应和多提供商支持</li>
|
||||
<li><strong>react-drawio</strong>:用于图表表示和操作</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
多提供商支持
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">多提供商支持</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>AWS Bedrock(默认)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>OpenAI / OpenAI兼容API(通过 <code>OPENAI_BASE_URL</code>)</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -267,15 +151,12 @@ export default function AboutCN() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
注意:<code>claude-sonnet-4-5</code> 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
支持与联系
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">支持与联系</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -286,24 +167,14 @@ export default function AboutCN() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
赞助
|
||||
</a>{" "}
|
||||
来帮助托管在线演示站点!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
如需支持或咨询,请在{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
GitHub仓库
|
||||
</a>{" "}
|
||||
上提交issue或联系:me[at]jiang.jp
|
||||
@@ -330,5 +201,5 @@ export default function AboutCN() {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
description:
|
||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: [
|
||||
"AIダイアグラム",
|
||||
"draw.io",
|
||||
"AWSアーキテクチャ",
|
||||
"GCPダイアグラム",
|
||||
"Azureダイアグラム",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"],
|
||||
};
|
||||
|
||||
export default function AboutJA() {
|
||||
return (
|
||||
@@ -24,23 +16,14 @@ export default function AboutJA() {
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||
エディタ
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
<Link href="/about/ja" className="text-blue-600 font-semibold">
|
||||
概要
|
||||
</Link>
|
||||
<a
|
||||
@@ -62,43 +45,22 @@ export default function AboutJA() {
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
AI搭載のダイアグラム作成ツール - チャット、描画、可視化
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
<Link href="/about/ja" className="text-blue-600 font-semibold">日本語</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
本アプリは最高のパフォーマンスを発揮するため、Claude
|
||||
Opus 4.5
|
||||
で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを
|
||||
Claude Haiku 4.5 に切り替えました。
|
||||
本アプリは最高のパフォーマンスを発揮するため、Claude Opus 4.5 で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを Claude Haiku 4.5 に切り替えました。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -107,176 +69,80 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
機能
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">機能</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM搭載のダイアグラム作成</strong>
|
||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||
</li>
|
||||
<li>
|
||||
<strong>画像ベースのダイアグラム複製</strong>
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
インタラクティブなチャットインターフェース
|
||||
</strong>
|
||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
AWSアーキテクチャダイアグラムサポート
|
||||
</strong>
|
||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
</li>
|
||||
<li><strong>LLM搭載のダイアグラム作成</strong>:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作</li>
|
||||
<li><strong>画像ベースのダイアグラム複製</strong>:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化</li>
|
||||
<li><strong>ダイアグラム履歴</strong>:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能</li>
|
||||
<li><strong>インタラクティブなチャットインターフェース</strong>:AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li>
|
||||
<li><strong>AWSアーキテクチャダイアグラムサポート</strong>:AWSアーキテクチャダイアグラムの生成を専門的にサポート</li>
|
||||
<li><strong>アニメーションコネクタ</strong>:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">例</h2>
|
||||
<p className="text-gray-700 mb-6">以下はいくつかのプロンプト例と生成されたダイアグラムです:</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アニメーションTransformerコネクタ
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">アニメーションTransformerコネクタ</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
付きのTransformerアーキテクチャ図を作成してください。
|
||||
<strong>プロンプト:</strong> <strong>アニメーションコネクタ</strong>付きのTransformerアーキテクチャ図を作成してください。
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width={480} height={360} className="mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCPアーキテクチャ図
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCPアーキテクチャ図</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>GCPアイコン</strong>
|
||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong> <strong>GCPアイコン</strong>を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCPアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/gcp_demo.svg" alt="GCPアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWSアーキテクチャ図
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWSアーキテクチャ図</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>AWSアイコン</strong>
|
||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong> <strong>AWSアイコン</strong>を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWSアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/aws_demo.svg" alt="AWSアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azureアーキテクチャ図
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azureアーキテクチャ図</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>Azureアイコン</strong>
|
||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong> <strong>Azureアイコン</strong>を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azureアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/azure_demo.svg" alt="Azureアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫のスケッチ
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫のスケッチ</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
かわいい猫を描いてください。
|
||||
<strong>プロンプト:</strong> かわいい猫を描いてください。
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫の絵"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/cat_demo.svg" alt="猫の絵" width={240} height={240} className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
仕組み
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
本アプリケーションは以下の技術を使用しています:
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">仕組み</h2>
|
||||
<p className="text-gray-700 mb-4">本アプリケーションは以下の技術を使用しています:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>
|
||||
:フロントエンドフレームワークとルーティング
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>
|
||||
:ダイアグラムの表現と操作
|
||||
</li>
|
||||
<li><strong>Next.js</strong>:フロントエンドフレームワークとルーティング</li>
|
||||
<li><strong>Vercel AI SDK</strong>(<code>ai</code> + <code>@ai-sdk/*</code>):ストリーミングAIレスポンスとマルチプロバイダーサポート</li>
|
||||
<li><strong>react-drawio</strong>:ダイアグラムの表現と操作</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
マルチプロバイダーサポート
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">マルチプロバイダーサポート</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>AWS Bedrock(デフォルト)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
経由)
|
||||
</li>
|
||||
<li>OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>経由)</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -285,15 +151,12 @@ export default function AboutJA() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
注:<code>claude-sonnet-4-5</code>はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">サポート&お問い合わせ</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -304,24 +167,14 @@ export default function AboutJA() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
スポンサー
|
||||
</a>{" "}
|
||||
をご検討ください!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
サポートやお問い合わせについては、{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
GitHubリポジトリ
|
||||
</a>{" "}
|
||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
||||
@@ -343,11 +196,10 @@ export default function AboutJA() {
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io -
|
||||
オープンソースAI搭載ダイアグラムジェネレーター
|
||||
Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
description:
|
||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: [
|
||||
"AI diagram",
|
||||
"draw.io",
|
||||
"AWS architecture",
|
||||
"GCP diagram",
|
||||
"Azure diagram",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: ["AI diagram", "draw.io", "AWS architecture", "GCP diagram", "Azure diagram", "LLM"],
|
||||
};
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
@@ -24,23 +16,14 @@ export default function About() {
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Editor
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
<Link href="/about" className="text-blue-600 font-semibold">
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
@@ -62,236 +45,105 @@ export default function About() {
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw, Visualize
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<Link href="/about" className="text-blue-600 font-semibold">English</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
This app is designed to run on Claude Opus 4.5 for
|
||||
best performance. However, due to
|
||||
higher-than-expected traffic, running the top-tier
|
||||
model has become cost-prohibitive. To avoid service
|
||||
interruptions and manage costs, I have switched the
|
||||
backend to Claude Haiku 4.5.
|
||||
This app is designed to run on Claude Opus 4.5 for best performance. However, due to higher-than-expected traffic, running the top-tier model has become cost-prohibitive. To avoid service interruptions and manage costs, I have switched the backend to Claude Haiku 4.5.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
A Next.js web application that integrates AI
|
||||
capabilities with draw.io diagrams. Create, modify, and
|
||||
enhance diagrams through natural language commands and
|
||||
AI-assisted visualization.
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
||||
Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Features
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Features</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM-Powered Diagram Creation</strong>:
|
||||
Leverage Large Language Models to create and
|
||||
manipulate draw.io diagrams directly through natural
|
||||
language commands
|
||||
</li>
|
||||
<li>
|
||||
<strong>Image-Based Diagram Replication</strong>:
|
||||
Upload existing diagrams or images and have the AI
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Diagram History</strong>: Comprehensive
|
||||
version control that tracks all changes, allowing
|
||||
you to view and restore previous versions of your
|
||||
diagrams before the AI editing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Interactive Chat Interface</strong>:
|
||||
Communicate with AI to refine your diagrams in
|
||||
real-time
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS Architecture Diagram Support</strong>:
|
||||
Specialized support for generating AWS architecture
|
||||
diagrams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Animated Connectors</strong>: Create dynamic
|
||||
and animated connectors between diagram elements for
|
||||
better visualization
|
||||
</li>
|
||||
<li><strong>LLM-Powered Diagram Creation</strong>: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands</li>
|
||||
<li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li>
|
||||
<li><strong>Diagram History</strong>: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing</li>
|
||||
<li><strong>Interactive Chat Interface</strong>: Communicate with AI to refine your diagrams in real-time</li>
|
||||
<li><strong>AWS Architecture Diagram Support</strong>: Specialized support for generating AWS architecture diagrams</li>
|
||||
<li><strong>Animated Connectors</strong>: Create dynamic and animated connectors between diagram elements for better visualization</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Examples
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Here are some example prompts and their generated
|
||||
diagrams:
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2>
|
||||
<p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated Transformer Connectors
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Animated Transformer Connectors</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated connector</strong> diagram of
|
||||
transformer's architecture.
|
||||
<strong>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer's architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="Transformer Architecture with Animated Connectors"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width={480} height={360} className="mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP Architecture Diagram
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP Architecture Diagram</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a GCP
|
||||
architecture diagram with{" "}
|
||||
<strong>GCP icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate a GCP architecture diagram with <strong>GCP icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/gcp_demo.svg" alt="GCP Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS Architecture Diagram
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS Architecture Diagram</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an AWS
|
||||
architecture diagram with{" "}
|
||||
<strong>AWS icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate an AWS architecture diagram with <strong>AWS icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/aws_demo.svg" alt="AWS Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure Architecture Diagram
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure Architecture Diagram</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an Azure
|
||||
architecture diagram with{" "}
|
||||
<strong>Azure icons</strong>. Users connect
|
||||
to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate an Azure architecture diagram with <strong>Azure icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/azure_demo.svg" alt="Azure Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Cat Sketch
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Cat Sketch</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Draw a cute cat for
|
||||
me.
|
||||
<strong>Prompt:</strong> Draw a cute cat for me.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="Cat Drawing"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Image src="/cat_demo.svg" alt="Cat Drawing" width={240} height={240} className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The application uses the following technologies:
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 mb-4">The application uses the following technologies:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>: For the frontend framework
|
||||
and routing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
||||
and multi-provider support
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>: For diagram
|
||||
representation and manipulation
|
||||
</li>
|
||||
<li><strong>Next.js</strong>: For the frontend framework and routing</li>
|
||||
<li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li>
|
||||
<li><strong>react-drawio</strong>: For diagram representation and manipulation</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Diagrams are represented as XML that can be rendered in
|
||||
draw.io. The AI processes your commands and generates or
|
||||
modifies this XML accordingly.
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Multi-Provider Support
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Multi-Provider Support</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>OpenAI / OpenAI-compatible APIs (via <code>OPENAI_BASE_URL</code>)</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -300,17 +152,12 @@ export default function About() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
draw.io diagrams with AWS logos, so if you want to
|
||||
create AWS architecture diagrams, this is the best
|
||||
choice.
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Support & Contact</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -321,24 +168,14 @@ export default function About() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
sponsoring
|
||||
</a>{" "}
|
||||
to help host the live demo site!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
For support or inquiries, please open an issue on the{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
GitHub repository
|
||||
</a>{" "}
|
||||
or contact: me[at]jiang.jp
|
||||
@@ -360,11 +197,10 @@ export default function About() {
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
||||
Generator
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram Generator
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,299 +1,293 @@
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import { z } from "zod"
|
||||
import { getAIModel } from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||
import { getAIModel } from '@/lib/ai-providers';
|
||||
import { findCachedResponse } from '@/lib/cached-responses';
|
||||
import { z } from "zod";
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
// 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 }
|
||||
}
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Helper function to check if diagram is minimal/empty
|
||||
function isMinimalDiagram(xml: string): boolean {
|
||||
const stripped = xml.replace(/\s/g, "")
|
||||
return !stripped.includes('id="2"')
|
||||
const stripped = xml.replace(/\s/g, '');
|
||||
return !stripped.includes('id="2"');
|
||||
}
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
const toolCallId = `cached-${Date.now()}`;
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.write({ type: "start" })
|
||||
writer.write({
|
||||
type: "tool-input-start",
|
||||
toolCallId,
|
||||
toolName: "display_diagram",
|
||||
})
|
||||
writer.write({
|
||||
type: "tool-input-delta",
|
||||
toolCallId,
|
||||
inputTextDelta: xml,
|
||||
})
|
||||
writer.write({
|
||||
type: "tool-input-available",
|
||||
toolCallId,
|
||||
toolName: "display_diagram",
|
||||
input: { xml },
|
||||
})
|
||||
writer.write({ type: "finish" })
|
||||
},
|
||||
})
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.write({ type: 'start' });
|
||||
writer.write({ type: 'tool-input-start', toolCallId, toolName: 'display_diagram' });
|
||||
writer.write({ type: 'tool-input-delta', toolCallId, inputTextDelta: xml });
|
||||
writer.write({ type: 'tool-input-available', toolCallId, toolName: 'display_diagram', input: { xml } });
|
||||
writer.write({ type: 'finish' });
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
|
||||
// Inner handler function
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Check for access code
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { messages, xml, sessionId } = await req.json()
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
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"
|
||||
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 ===
|
||||
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200
|
||||
? sessionId
|
||||
: undefined;
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
||||
const isFirstMessage = messages.length === 1;
|
||||
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
|
||||
|
||||
if (isFirstMessage && isEmptyDiagram) {
|
||||
const lastMessage = messages[0]
|
||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
||||
const lastMessage = messages[0];
|
||||
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text');
|
||||
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file');
|
||||
|
||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
||||
const cached = findCachedResponse(textPart?.text || '', !!filePart);
|
||||
|
||||
if (cached) {
|
||||
console.log(
|
||||
"[Cache] Returning cached response for:",
|
||||
textPart?.text,
|
||||
)
|
||||
return createCachedStreamResponse(cached.xml)
|
||||
}
|
||||
if (cached) {
|
||||
console.log('[Cache] Returning cached response for:', textPart?.text);
|
||||
return createCachedStreamResponse(cached.xml);
|
||||
}
|
||||
}
|
||||
// === CACHE CHECK END ===
|
||||
|
||||
// Get AI model from environment configuration
|
||||
const { model, providerOptions, headers, modelId } = getAIModel()
|
||||
const systemMessage = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see the image that user uploaded.
|
||||
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId)
|
||||
You utilize the following tools:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
description: 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
|
||||
}
|
||||
---Tool2---
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
edits: Array<{search: string, replace: string}>
|
||||
}
|
||||
---End of tools---
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
|
||||
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
|
||||
- Adapt artistic concepts into abstract diagram representations using available shapes
|
||||
- Optimize element positioning to prevent overlapping and maintain readability
|
||||
- Structure complex systems into clear, organized visual components
|
||||
|
||||
Layout constraints:
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||
|
||||
Note that:
|
||||
- Use proper tool calls to generate or edit diagrams;
|
||||
- never return raw XML in text responses,
|
||||
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
|
||||
|
||||
When using edit_diagram tool:
|
||||
- Keep edits minimal - only include the specific line being changed plus 1-2 context lines
|
||||
- Example GOOD edit: {"search": " <mxCell id=\"2\" value=\"Old Text\">", "replace": " <mxCell id=\"2\" value=\"New Text\">"}
|
||||
- Example BAD edit: Including 10+ unchanged lines just to change one attribute
|
||||
- For multiple changes, use separate edits: [{"search": "line1", "replace": "new1"}, {"search": "line2", "replace": "new2"}]
|
||||
- RETRY POLICY: If edit_diagram fails because the search pattern cannot be found:
|
||||
* You may retry edit_diagram up to 3 times with adjusted search patterns
|
||||
* After 3 failed attempts, you MUST fall back to using display_diagram to regenerate the entire diagram
|
||||
* The error message will indicate how many retries remain
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
Basic structure:
|
||||
\`\`\`xml
|
||||
<mxGraphModel>
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<!-- All other cells go here as siblings -->
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
\`\`\`
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
|
||||
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
|
||||
3. Use unique sequential IDs for all cells (start from "2" for user content)
|
||||
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
|
||||
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>
|
||||
\`\`\`
|
||||
|
||||
Connector (edge) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
`;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Extract text from the last message parts
|
||||
const lastMessageText =
|
||||
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
|
||||
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
|
||||
|
||||
// Extract file parts (images) from the last message
|
||||
const fileParts =
|
||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
||||
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
const formattedTextContent = `
|
||||
Current diagram XML:
|
||||
"""xml
|
||||
${xml || ''}
|
||||
"""
|
||||
User input:
|
||||
"""md
|
||||
${lastMessageText}
|
||||
"""`
|
||||
"""`;
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = convertToModelMessages(messages)
|
||||
const modelMessages = convertToModelMessages(messages);
|
||||
|
||||
// Log messages with empty content for debugging (helps identify root cause)
|
||||
const emptyMessages = modelMessages.filter((msg: any) =>
|
||||
!msg.content || !Array.isArray(msg.content) || msg.content.length === 0
|
||||
);
|
||||
if (emptyMessages.length > 0) {
|
||||
console.warn('[Chat API] Messages with empty content detected:',
|
||||
JSON.stringify(emptyMessages.map((m: any) => ({ role: m.role, contentLength: m.content?.length })))
|
||||
);
|
||||
console.warn('[Chat API] Original UI messages structure:',
|
||||
JSON.stringify(messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
partsCount: m.parts?.length,
|
||||
partTypes: m.parts?.map((p: any) => p.type)
|
||||
})))
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||
let enhancedMessages = modelMessages.filter(
|
||||
(msg: any) =>
|
||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
||||
)
|
||||
let enhancedMessages = modelMessages.filter((msg: any) =>
|
||||
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 formatted content if it's a user message
|
||||
if (enhancedMessages.length >= 1) {
|
||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
|
||||
if (lastModelMessage.role === "user") {
|
||||
// Build content array with user input text and file parts
|
||||
const contentParts: any[] = [
|
||||
{ type: "text", text: formattedUserInput },
|
||||
]
|
||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1];
|
||||
if (lastModelMessage.role === 'user') {
|
||||
// Build content array with text and file parts
|
||||
const contentParts: any[] = [
|
||||
{ type: 'text', text: formattedTextContent }
|
||||
];
|
||||
|
||||
// Add image parts back
|
||||
for (const filePart of fileParts) {
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
enhancedMessages = [
|
||||
...enhancedMessages.slice(0, -1),
|
||||
{ ...lastModelMessage, content: contentParts },
|
||||
]
|
||||
// Add image parts back
|
||||
for (const filePart of fileParts) {
|
||||
contentParts.push({
|
||||
type: 'image',
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType
|
||||
});
|
||||
}
|
||||
|
||||
enhancedMessages = [
|
||||
...enhancedMessages.slice(0, -1),
|
||||
{ ...lastModelMessage, content: contentParts }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache point to the last assistant message in conversation history
|
||||
// This caches the entire conversation prefix for subsequent requests
|
||||
// Strategy: system (cached) + history with last assistant (cached) + new user message
|
||||
if (enhancedMessages.length >= 2) {
|
||||
// Find the last assistant message (should be second-to-last, before current user message)
|
||||
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
||||
if (enhancedMessages[i].role === "assistant") {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}
|
||||
break // Only cache the last assistant message
|
||||
}
|
||||
// Find the last assistant message (should be second-to-last, before current user message)
|
||||
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
||||
if (enhancedMessages[i].role === 'assistant') {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: 'default' } },
|
||||
},
|
||||
};
|
||||
break; // Only cache the last assistant message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System messages with multiple cache breakpoints for optimal caching:
|
||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// This allows: if only user message changes, both system caches are reused
|
||||
// if XML changes, instruction cache is still reused
|
||||
const systemMessages = [
|
||||
// Cache breakpoint 1: Instructions (rarely change)
|
||||
{
|
||||
role: "system" as const,
|
||||
content: systemMessage,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
},
|
||||
// Cache breakpoint 2: Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `Current diagram XML:\n"""xml\n${xml || ""}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
},
|
||||
]
|
||||
// Get AI model from environment configuration
|
||||
const { model, providerOptions, headers } = getAIModel();
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
// System message with cache point for Bedrock (requires 1024+ tokens)
|
||||
const systemMessageWithCache = {
|
||||
role: 'system' as const,
|
||||
content: systemMessage,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: 'default' } },
|
||||
},
|
||||
};
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }),
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
sessionId: validSessionId,
|
||||
userId,
|
||||
}),
|
||||
}),
|
||||
onFinish: ({ text, usage, providerMetadata }) => {
|
||||
console.log(
|
||||
"[Cache] Full providerMetadata:",
|
||||
JSON.stringify(providerMetadata, null, 2),
|
||||
)
|
||||
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
})
|
||||
model,
|
||||
messages: [systemMessageWithCache, ...enhancedMessages],
|
||||
...(providerOptions && { providerOptions }),
|
||||
...(headers && { headers }),
|
||||
// Only enable telemetry if Langfuse is configured
|
||||
...(process.env.LANGFUSE_PUBLIC_KEY && {
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
metadata: {
|
||||
sessionId: validSessionId,
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
|
||||
}),
|
||||
onFinish: ({ usage, providerMetadata }) => {
|
||||
console.log('[Cache] Usage:', JSON.stringify({
|
||||
inputTokens: usage?.inputTokens,
|
||||
outputTokens: usage?.outputTokens,
|
||||
cachedInputTokens: usage?.cachedInputTokens,
|
||||
}, null, 2));
|
||||
console.log('[Cache] Provider metadata:', JSON.stringify(providerMetadata, null, 2));
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. All mxCell elements must be DIRECT children of <root> - never nested
|
||||
@@ -328,63 +322,58 @@ Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
|
||||
inputSchema: z.object({
|
||||
xml: z.string().describe("XML string to be displayed on draw.io")
|
||||
})
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
IMPORTANT: Keep edits concise:
|
||||
- COPY the exact mxCell line from the current XML (attribute order matters!)
|
||||
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
|
||||
- Break large changes into multiple smaller edits
|
||||
- Each search must contain complete lines (never truncate mid-line)
|
||||
- First match only - be specific enough to target the right element`,
|
||||
inputSchema: z.object({
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
search: z
|
||||
.string()
|
||||
.describe(
|
||||
"EXACT lines copied from current XML (preserve attribute order!)",
|
||||
),
|
||||
replace: z
|
||||
.string()
|
||||
.describe("Replacement lines"),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Array of search/replace pairs to apply sequentially",
|
||||
),
|
||||
}),
|
||||
},
|
||||
inputSchema: z.object({
|
||||
edits: z.array(z.object({
|
||||
search: z.string().describe("Exact lines to search for (including whitespace and indentation)"),
|
||||
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) {
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
try {
|
||||
return await handleChatRequest(req)
|
||||
} catch (error) {
|
||||
console.error("Error in chat route:", error)
|
||||
return Response.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
)
|
||||
const errorString = typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: JSON.stringify(error);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap with Langfuse observe (if configured)
|
||||
const observedHandler = wrapWithObserve(safeHandler)
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return observedHandler(req)
|
||||
return result.toUIMessageStreamResponse({
|
||||
onError: errorHandler,
|
||||
});
|
||||
} 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
353
app/globals.css
353
app/globals.css
@@ -1,259 +1,248 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.5 0.02 260);
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.50 0.02 260);
|
||||
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.60 0.20 25);
|
||||
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.7 0.18 45);
|
||||
--chart-4: oklch(0.6 0.2 330);
|
||||
--chart-5: oklch(0.5 0.15 200);
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.70 0.18 45);
|
||||
--chart-4: oklch(0.60 0.20 330);
|
||||
--chart-5: oklch(0.50 0.15 200);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--card: oklch(0.2 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
--card: oklch(0.20 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--popover: oklch(0.2 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
--popover: oklch(0.20 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--primary: oklch(0.7 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
--primary: oklch(0.70 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.9 0.01 260);
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.90 0.01 260);
|
||||
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
--accent: oklch(0.30 0.04 280);
|
||||
--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);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.7 0.16 265);
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.70 0.16 265);
|
||||
|
||||
--chart-1: oklch(0.7 0.16 265);
|
||||
--chart-2: oklch(0.7 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.7 0.18 330);
|
||||
--chart-5: oklch(0.6 0.13 200);
|
||||
--chart-1: oklch(0.70 0.16 265);
|
||||
--chart-2: oklch(0.70 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.70 0.18 330);
|
||||
--chart-5: oklch(0.60 0.13 200);
|
||||
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.7 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.70 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.90 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.70 0.16 265);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
||||
[data-slot="scroll-area-viewport"] > div {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
}
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Message bubble animations */
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-message-in {
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Subtle floating shadow for cards */
|
||||
.shadow-soft {
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
}
|
||||
|
||||
.shadow-soft-lg {
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
}
|
||||
|
||||
/* Gradient text utility */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.55 0.18 265),
|
||||
oklch(0.6 0.2 290)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import { Analytics } from "@vercel/analytics/react"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import type { Metadata } from "next";
|
||||
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { GoogleAnalytics } from "@next/third-parties/google";
|
||||
import { DiagramProvider } from "@/contexts/diagram-context";
|
||||
|
||||
import "./globals.css"
|
||||
import "./globals.css";
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
})
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: [
|
||||
"AI diagram generator",
|
||||
"AWS architecture",
|
||||
"flowchart creator",
|
||||
"draw.io",
|
||||
"AI drawing tool",
|
||||
"technical diagrams",
|
||||
"diagram automation",
|
||||
"free diagram generator",
|
||||
"online diagram maker",
|
||||
],
|
||||
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: ["AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker"],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
@@ -64,8 +45,7 @@ export const metadata: Metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Free, no login required.",
|
||||
description: "Create professional diagrams with AI assistance. Free, no login required.",
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
@@ -82,28 +62,27 @@ export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Next AI Draw.io",
|
||||
applicationCategory: "DesignApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
description:
|
||||
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Next AI Draw.io',
|
||||
applicationCategory: 'DesignApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
description: 'AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.',
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -117,11 +96,12 @@ export default function RootLayout({
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
|
||||
<Analytics />
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
)}
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
183
app/page.tsx
183
app/page.tsx
@@ -1,136 +1,85 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DrawIoEmbed } from "react-drawio";
|
||||
import ChatPanel from "@/components/chat-panel";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { Monitor } from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport } = useDiagram()
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("drawio-theme")
|
||||
if (saved === "min" || saved === "sketch") return saved
|
||||
}
|
||||
return "min"
|
||||
})
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const { drawioRef, handleDiagramExport } = useDiagram();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isChatVisible, setIsChatVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
||||
event.preventDefault()
|
||||
toggleChatPanel()
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
|
||||
event.preventDefault();
|
||||
setIsChatVisible((prev) => !prev);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
// This helps prevent accidental navigation from browser back gestures
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [])
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full"
|
||||
>
|
||||
{/* Draw.io Canvas */}
|
||||
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
|
||||
<div
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||
<DrawIoEmbed
|
||||
key={drawioUi}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-screen bg-background relative overflow-hidden">
|
||||
{/* Mobile warning overlay */}
|
||||
{isMobile && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||
<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>
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
{/* Draw.io Canvas */}
|
||||
<div
|
||||
className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
|
||||
>
|
||||
<div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
ref={chatPanelRef}
|
||||
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"}`}>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={() => {
|
||||
const newTheme =
|
||||
drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newTheme)
|
||||
setDrawioUi(newTheme)
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
{/* Chat Panel */}
|
||||
<div
|
||||
className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
|
||||
>
|
||||
<div className="h-full py-2 pr-2">
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/api/",
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/api/',
|
||||
},
|
||||
sitemap: "https://next-ai-drawio.jiang.jp/sitemap.xml",
|
||||
sitemap: 'https://next-ai-drawio.jiang.jp/sitemap.xml',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://next-ai-drawio.jiang.jp/about",
|
||||
url: 'https://next-ai-drawio.jiang.jp/about',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
]
|
||||
|
||||
83
biome.json
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,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type React from "react"
|
||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
||||
import React from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
} from "@/components/ui/tooltip";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
interface ButtonWithTooltipProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
tooltipContent: string
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
tooltipContent: string;
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
@@ -27,10 +27,8 @@ export function ButtonWithTooltip({
|
||||
<TooltipTrigger asChild>
|
||||
<Button {...buttonProps}>{children}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
</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 {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
||||
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExamplePanel({
|
||||
setInput,
|
||||
setFiles,
|
||||
}: {
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
}) {
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.")
|
||||
setInput("Replicate this flowchart.");
|
||||
|
||||
try {
|
||||
const response = await fetch("/example.png")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
const response = await fetch("/example.png");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "example.png", { type: "image/png" });
|
||||
setFiles([file]);
|
||||
} catch (error) {
|
||||
console.error("Error loading example image:", error)
|
||||
console.error("Error loading example image:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicateArchitecture = async () => {
|
||||
setInput("Replicate this in aws style")
|
||||
setInput("Replicate this in aws style");
|
||||
|
||||
try {
|
||||
const response = await fetch("/architecture.png")
|
||||
const blob = await response.blob()
|
||||
const response = await fetch("/architecture.png");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
})
|
||||
setFiles([file])
|
||||
});
|
||||
setFiles([file]);
|
||||
} catch (error) {
|
||||
console.error("Error loading architecture image:", error)
|
||||
console.error("Error loading architecture image:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
@@ -75,8 +75,7 @@ export default function ExamplePanel({
|
||||
Create diagrams with AI
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
Describe what you want to create or upload an image to
|
||||
replicate
|
||||
Describe what you want to create or upload an image to replicate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -91,12 +90,7 @@ export default function ExamplePanel({
|
||||
icon={<Zap className="w-4 h-4 text-primary" />}
|
||||
title="Animated Diagram"
|
||||
description="Draw a transformer architecture with animated connectors"
|
||||
onClick={() => {
|
||||
setInput(
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
)
|
||||
setFiles([])
|
||||
}}
|
||||
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
@@ -117,10 +111,7 @@ export default function ExamplePanel({
|
||||
icon={<Palette className="w-4 h-4 text-primary" />}
|
||||
title="Creative Drawing"
|
||||
description="Draw something fun and creative"
|
||||
onClick={() => {
|
||||
setInput("Draw a cat for me")
|
||||
setFiles([])
|
||||
}}
|
||||
onClick={() => setInput("Draw a cat for me")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,5 +120,5 @@ export default function ExamplePanel({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,130 +1,34 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||
import { SaveDialog } from "@/components/save-dialog";
|
||||
import {
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
PenTool,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
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>,
|
||||
)
|
||||
}
|
||||
}
|
||||
Image as ImageIcon,
|
||||
History,
|
||||
Download,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { FilePreviewList } from "./file-preview-list";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { HistoryDialog } from "@/components/history-dialog";
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onClearChat: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
showHistory?: boolean
|
||||
onToggleHistory?: (show: boolean) => void
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
drawioUi?: "min" | "sketch"
|
||||
onToggleDrawioUi?: () => void
|
||||
input: string;
|
||||
status: "submitted" | "streaming" | "ready" | "error";
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onClearChat: () => void;
|
||||
files?: File[];
|
||||
onFileChange?: (files: File[]) => void;
|
||||
showHistory?: boolean;
|
||||
onToggleHistory?: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -137,144 +41,124 @@ export function ChatInput({
|
||||
onFileChange = () => {},
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
sessionId,
|
||||
error = null,
|
||||
drawioUi = "min",
|
||||
onToggleDrawioUi = () => {},
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [showThemeWarning, setShowThemeWarning] = useState(false)
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
const isDisabled = status === "streaming" || status === "submitted";
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
||||
}, [status, isDisabled]);
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
}, [input, adjustTextareaHeight]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.closest("form");
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit()
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
if (isDisabled) return;
|
||||
|
||||
const items = e.clipboardData.items
|
||||
const items = e.clipboardData.items;
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
item.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
const imageFiles = await Promise.all(
|
||||
imageItems.map(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return null;
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
|
||||
{
|
||||
type: file.type,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
)
|
||||
showValidationErrors(errors)
|
||||
const validFiles = imageFiles.filter(
|
||||
(file): file is File => file !== null
|
||||
);
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
onFileChange([...files, ...validFiles]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(newFiles, files.length)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
const newFiles = Array.from(e.target.files || []);
|
||||
onFileChange([...files, ...newFiles]);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
onFileChange(files.filter((file) => file !== fileToRemove));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isDisabled) return
|
||||
if (isDisabled) return;
|
||||
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
)
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
if (imageFiles.length > 0) {
|
||||
onFileChange([...files, ...imageFiles]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onClearChat()
|
||||
setShowClearDialog(false)
|
||||
}
|
||||
onClearChat();
|
||||
setShowClearDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -291,10 +175,7 @@ export function ChatInput({
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
/>
|
||||
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -303,7 +184,7 @@ export function ChatInput({
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Describe your diagram or paste an image..."
|
||||
@@ -337,60 +218,6 @@ export function ChatInput({
|
||||
showHistory={showHistory}
|
||||
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>
|
||||
|
||||
{/* Right actions */}
|
||||
@@ -422,12 +249,8 @@ export function ChatInput({
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
onSave={saveDiagramToFile}
|
||||
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
||||
/>
|
||||
|
||||
<ButtonWithTooltip
|
||||
@@ -459,9 +282,7 @@ export function ChatInput({
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={
|
||||
isDisabled ? "Sending..." : "Send message"
|
||||
}
|
||||
aria-label={isDisabled ? "Sending..." : "Send message"}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -475,6 +296,7 @@ export function ChatInput({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,24 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Cpu,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
convertToLegalXml,
|
||||
replaceNodes,
|
||||
validateMxCellStructure,
|
||||
} from "@/lib/utils"
|
||||
import ExamplePanel from "./chat-example-panel"
|
||||
import { CodeBlock } from "./code-block"
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import ExamplePanel from "./chat-example-panel";
|
||||
import { UIMessage } from "ai";
|
||||
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
||||
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus } from "lucide-react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
interface EditPair {
|
||||
search: string
|
||||
replace: string
|
||||
}
|
||||
|
||||
// Tool part interface for type safety
|
||||
interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
|
||||
output?: string
|
||||
search: string;
|
||||
replace: string;
|
||||
}
|
||||
|
||||
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{edits.map((edit, index) => (
|
||||
<div
|
||||
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50">
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Change {index + 1}
|
||||
@@ -60,9 +29,7 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Minus className="w-3 h-3 text-red-500" />
|
||||
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
|
||||
Remove
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{edit.search}
|
||||
@@ -72,9 +39,7 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Plus className="w-3 h-3 text-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
|
||||
Add
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{edit.replace}
|
||||
@@ -84,190 +49,139 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
|
||||
const getMessageTextContent = (message: UIMessage): string => {
|
||||
if (!message.parts) return ""
|
||||
if (!message.parts) return "";
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part as { text: string }).text)
|
||||
.join("\n")
|
||||
}
|
||||
.filter((part: any) => part.type === "text")
|
||||
.map((part: any) => part.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
interface ChatMessageDisplayProps {
|
||||
messages: UIMessage[]
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
sessionId?: string
|
||||
onRegenerate?: (messageIndex: number) => void
|
||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||
messages: UIMessage[];
|
||||
error?: Error | null;
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
messages,
|
||||
error,
|
||||
setInput,
|
||||
setFiles,
|
||||
sessionId,
|
||||
onRegenerate,
|
||||
onEditMessage,
|
||||
}: ChatMessageDisplayProps) {
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const previousXML = useRef<string>("")
|
||||
const processedToolCalls = useRef<Set<string>>(new Set())
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const previousXML = useRef<string>("");
|
||||
const processedToolCalls = useRef<Set<string>>(new Set());
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [editText, setEditText] = useState<string>("")
|
||||
{}
|
||||
);
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
|
||||
|
||||
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedMessageId(messageId);
|
||||
setTimeout(() => setCopiedMessageId(null), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy message:", err)
|
||||
setCopyFailedMessageId(messageId)
|
||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||
console.error("Failed to copy message:", err);
|
||||
setCopyFailedMessageId(messageId);
|
||||
setTimeout(() => setCopyFailedMessageId(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
|
||||
// Toggle off if already selected
|
||||
if (feedback[messageId] === value) {
|
||||
setFeedback((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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(
|
||||
(xml: string) => {
|
||||
const currentXml = xml || ""
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
const currentXml = xml || "";
|
||||
const convertedXml = convertToLegalXml(currentXml);
|
||||
if (convertedXml !== previousXML.current) {
|
||||
const replacedXML = replaceNodes(chartXML, convertedXml)
|
||||
const replacedXML = replaceNodes(chartXML, convertedXml);
|
||||
|
||||
const validationError = validateMxCellStructure(replacedXML)
|
||||
const validationError = validateMxCellStructure(replacedXML);
|
||||
if (!validationError) {
|
||||
previousXML.current = convertedXml
|
||||
onDisplayChart(replacedXML)
|
||||
previousXML.current = convertedXml;
|
||||
onDisplayChart(replacedXML);
|
||||
} else {
|
||||
console.log(
|
||||
"[ChatMessageDisplay] XML validation failed:",
|
||||
validationError,
|
||||
)
|
||||
console.error("[ChatMessageDisplay] XML validation failed:", validationError);
|
||||
}
|
||||
}
|
||||
},
|
||||
[chartXML, onDisplayChart],
|
||||
)
|
||||
[chartXML, onDisplayChart]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingMessageId && editTextareaRef.current) {
|
||||
editTextareaRef.current.focus()
|
||||
}
|
||||
}, [editingMessageId])
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
messages.forEach((message) => {
|
||||
if (message.parts) {
|
||||
message.parts.forEach((part) => {
|
||||
message.parts.forEach((part: any) => {
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
const toolPart = part as ToolPartLike
|
||||
const { toolCallId, state, input } = toolPart
|
||||
const { toolCallId, state } = part;
|
||||
|
||||
if (state === "output-available") {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[toolCallId]: false,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool-display_diagram" &&
|
||||
input?.xml
|
||||
part.input?.xml
|
||||
) {
|
||||
const xml = input.xml as string
|
||||
if (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available"
|
||||
) {
|
||||
handleDisplayChart(xml)
|
||||
handleDisplayChart(part.input.xml);
|
||||
} else if (
|
||||
state === "output-available" &&
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
handleDisplayChart(xml)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
handleDisplayChart(part.input.xml);
|
||||
processedToolCalls.current.add(toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}, [messages, handleDisplayChart])
|
||||
});
|
||||
}, [messages, handleDisplayChart]);
|
||||
|
||||
const renderToolPart = (part: ToolPartLike) => {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const renderToolPart = (part: any) => {
|
||||
const callId = part.toolCallId;
|
||||
const { state, input, output } = part;
|
||||
const isExpanded = expandedTools[callId] ?? true;
|
||||
const toolName = part.type?.replace("tool-", "");
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[callId]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
return "Generate Diagram";
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
return "Edit Diagram";
|
||||
default:
|
||||
return name
|
||||
return name;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -299,7 +213,6 @@ export function ChatMessageDisplay({
|
||||
)}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
@@ -316,16 +229,10 @@ export function ChatMessageDisplay({
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
) : typeof input === "object" &&
|
||||
input.edits &&
|
||||
Array.isArray(input.edits) ? (
|
||||
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
|
||||
<EditDiffDisplay edits={input.edits} />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
) : typeof input === "object" && Object.keys(input).length > 0 ? (
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -335,408 +242,96 @@ export function ChatMessageDisplay({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
<ScrollArea className="h-full px-4 scrollbar-thin">
|
||||
{messages.length === 0 ? (
|
||||
<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) => {
|
||||
const userMessageText =
|
||||
message.role === "user"
|
||||
? getMessageTextContent(message)
|
||||
: ""
|
||||
const isLastAssistantMessage =
|
||||
message.role === "assistant" &&
|
||||
(messageIndex === messages.length - 1 ||
|
||||
messages
|
||||
.slice(messageIndex + 1)
|
||||
.every((m) => m.role !== "assistant"))
|
||||
const isLastUserMessage =
|
||||
message.role === "user" &&
|
||||
(messageIndex === messages.length - 1 ||
|
||||
messages
|
||||
.slice(messageIndex + 1)
|
||||
.every((m) => m.role !== "user"))
|
||||
const isEditing = editingMessageId === message.id
|
||||
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
style={{
|
||||
animationDelay: `${messageIndex * 50}ms`,
|
||||
}}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
||||
>
|
||||
{message.role === "user" &&
|
||||
userMessageText &&
|
||||
!isEditing && (
|
||||
<div className="flex items-center gap-1 self-center mr-2">
|
||||
{/* Edit button - only on last user message */}
|
||||
{onEditMessage &&
|
||||
isLastUserMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title="Edit message"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyMessageToClipboard(
|
||||
message.id,
|
||||
userMessageText,
|
||||
)
|
||||
{message.role === "user" && userMessageText && (
|
||||
<button
|
||||
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors self-center mr-2"
|
||||
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
|
||||
>
|
||||
{copiedMessageId === message.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : copyFailedMessageId === message.id ? (
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="max-w-[85%]">
|
||||
{/* Text content in bubble */}
|
||||
{message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
|
||||
<div
|
||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
{message.parts?.map((part: any, index: number) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap break-words">
|
||||
{part.text}
|
||||
</div>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<div key={index} className="mt-2">
|
||||
<Image
|
||||
src={part.url}
|
||||
width={200}
|
||||
height={200}
|
||||
alt={`Uploaded diagram or image for AI analysis`}
|
||||
className="rounded-lg border border-white/20"
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
: copyFailedMessageId ===
|
||||
message.id
|
||||
? "Failed to copy"
|
||||
: "Copy message"
|
||||
}
|
||||
>
|
||||
{copiedMessageId ===
|
||||
message.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : copyFailedMessageId ===
|
||||
message.id ? (
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
{/* Edit mode for user messages */}
|
||||
{isEditing && message.role === "user" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
ref={editTextareaRef}
|
||||
value={editText}
|
||||
onChange={(e) =>
|
||||
setEditText(e.target.value)
|
||||
}
|
||||
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={Math.min(
|
||||
editText.split("\n")
|
||||
.length + 1,
|
||||
6,
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
} else if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey)
|
||||
) {
|
||||
e.preventDefault()
|
||||
if (
|
||||
editText.trim() &&
|
||||
onEditMessage
|
||||
) {
|
||||
onEditMessage(
|
||||
messageIndex,
|
||||
editText.trim(),
|
||||
)
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
editText.trim() &&
|
||||
onEditMessage
|
||||
) {
|
||||
onEditMessage(
|
||||
messageIndex,
|
||||
editText.trim(),
|
||||
)
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}
|
||||
}}
|
||||
disabled={!editText.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Save & Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Text content in bubble */
|
||||
message.parts?.some(
|
||||
(part) =>
|
||||
part.type === "text" ||
|
||||
part.type === "file",
|
||||
) && (
|
||||
<div
|
||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||
: message.role ===
|
||||
"system"
|
||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||
role={
|
||||
message.role === "user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? "button"
|
||||
: undefined
|
||||
}
|
||||
tabIndex={
|
||||
message.role === "user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? 0
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
) {
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === "Enter" ||
|
||||
e.key === " ") &&
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
) {
|
||||
e.preventDefault()
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}
|
||||
}}
|
||||
title={
|
||||
message.role === "user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? "Click to edit"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{message.parts?.map(
|
||||
(part, index) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-text-${index}`}
|
||||
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||
message.role ===
|
||||
"user"
|
||||
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
||||
: "dark:prose-invert"
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown>
|
||||
{
|
||||
part.text
|
||||
}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
case "file":
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-file-${part.url}`}
|
||||
className="mt-2"
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
part.url
|
||||
}
|
||||
width={
|
||||
200
|
||||
}
|
||||
height={
|
||||
200
|
||||
}
|
||||
alt={`Uploaded diagram or image for AI analysis`}
|
||||
className="rounded-lg border border-white/20"
|
||||
style={{
|
||||
objectFit:
|
||||
"contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{/* Tool calls outside bubble */}
|
||||
{message.parts?.map((part) => {
|
||||
{message.parts?.map((part: any) => {
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
return renderToolPart(
|
||||
part as ToolPartLike,
|
||||
)
|
||||
return renderToolPart(part);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
})}
|
||||
{/* Action buttons for assistant messages */}
|
||||
{message.role === "assistant" && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{/* Copy button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyMessageToClipboard(
|
||||
message.id,
|
||||
getMessageTextContent(
|
||||
message,
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "text-green-600 bg-green-100"
|
||||
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
: "Copy response"
|
||||
}
|
||||
>
|
||||
{copiedMessageId ===
|
||||
message.id ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Regenerate button - only on last assistant message */}
|
||||
{onRegenerate &&
|
||||
isLastAssistantMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRegenerate(
|
||||
messageIndex,
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Regenerate response"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
{/* Thumbs up */}
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{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} />
|
||||
</ScrollArea>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,28 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import {
|
||||
CheckCircle,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { Toaster } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import {
|
||||
SettingsDialog,
|
||||
STORAGE_ACCESS_CODE_KEY,
|
||||
} from "@/components/settings-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { formatXML, validateMxCellStructure } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import type React from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { ChatInput } from "@/components/chat-input";
|
||||
import { ChatMessageDisplay } from "./chat-message-display";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean
|
||||
onToggleVisibility: () => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
isMobile?: boolean
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
}
|
||||
|
||||
export default function ChatPanel({
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
isMobile = false,
|
||||
}: ChatPanelProps) {
|
||||
const {
|
||||
loadDiagram: onDisplayChart,
|
||||
@@ -47,61 +31,38 @@ export default function ChatPanel({
|
||||
resolverRef,
|
||||
chartXML,
|
||||
clearDiagram,
|
||||
} = useDiagram()
|
||||
} = useDiagram();
|
||||
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
if (resolverRef && "current" in resolverRef) {
|
||||
resolverRef.current = resolve
|
||||
resolverRef.current = resolve;
|
||||
}
|
||||
if (saveToHistory) {
|
||||
onExport()
|
||||
onExport();
|
||||
} else {
|
||||
handleExportWithoutHistory()
|
||||
handleExportWithoutHistory();
|
||||
}
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"Chart export timed out after 10 seconds",
|
||||
),
|
||||
new Error("Chart export timed out after 10 seconds")
|
||||
),
|
||||
10000,
|
||||
),
|
||||
10000
|
||||
)
|
||||
),
|
||||
])
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
// Check if access code is required on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/config")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
|
||||
.catch(() => setAccessCodeRequired(false))
|
||||
}, [])
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// Generate a unique session ID for Langfuse tracing
|
||||
const [sessionId, setSessionId] = useState(
|
||||
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
)
|
||||
|
||||
// Store XML snapshots for each user message (keyed by message index)
|
||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
||||
|
||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||
const chartXMLRef = useRef(chartXML)
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
|
||||
|
||||
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
||||
useChat({
|
||||
@@ -110,71 +71,50 @@ export default function ChatPanel({
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
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) {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: validationError,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
});
|
||||
}
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
const { edits } = toolCall.input as {
|
||||
edits: Array<{ search: string; replace: string }>
|
||||
}
|
||||
edits: Array<{ search: string; replace: string }>;
|
||||
};
|
||||
|
||||
let currentXml = ""
|
||||
let currentXml = "";
|
||||
try {
|
||||
console.log("[edit_diagram] Starting...")
|
||||
// Use chartXML from ref directly - more reliable than export
|
||||
// especially on Vercel where DrawIO iframe may have latency issues
|
||||
// Using ref to avoid stale closure in callback
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
console.log(
|
||||
"[edit_diagram] Using cached chartXML, length:",
|
||||
currentXml.length,
|
||||
)
|
||||
} else {
|
||||
// Fallback to export only if no cached XML
|
||||
console.log(
|
||||
"[edit_diagram] No cached XML, fetching from DrawIO...",
|
||||
)
|
||||
currentXml = await onFetchChart(false)
|
||||
console.log(
|
||||
"[edit_diagram] Got XML from export, length:",
|
||||
currentXml.length,
|
||||
)
|
||||
}
|
||||
// Fetch without saving to history - edit_diagram shouldn't create history entry
|
||||
currentXml = await onFetchChart(false);
|
||||
|
||||
const { replaceXMLParts } = await import("@/lib/utils")
|
||||
const editedXml = replaceXMLParts(currentXml, edits)
|
||||
const { replaceXMLParts } = await import("@/lib/utils");
|
||||
const editedXml = replaceXMLParts(currentXml, edits);
|
||||
|
||||
onDisplayChart(editedXml)
|
||||
onDisplayChart(editedXml);
|
||||
|
||||
addToolResult({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||
})
|
||||
console.log("[edit_diagram] Success")
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
console.error("Edit diagram failed:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
: String(error);
|
||||
|
||||
addToolResult({
|
||||
tool: "edit_diagram",
|
||||
@@ -183,84 +123,58 @@ export default function ChatPanel({
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml || "No XML available"}
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
console.error("Chat error:", error);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages])
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[ChatPanel] Status changed to:", status);
|
||||
}, [status]);
|
||||
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
e.preventDefault();
|
||||
const isProcessing = status === "streaming" || status === "submitted";
|
||||
if (input.trim() && !isProcessing) {
|
||||
try {
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(chartXml)
|
||||
let chartXml = await onFetchChart();
|
||||
chartXml = formatXML(chartXml);
|
||||
|
||||
// Update ref directly to avoid race condition with React's async state update
|
||||
// This ensures edit_diagram has the correct XML before AI responds
|
||||
chartXMLRef.current = chartXml
|
||||
|
||||
const parts: any[] = [{ type: "text", text: input }]
|
||||
const parts: any[] = [{ type: "text", text: input }];
|
||||
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
const reader = new FileReader()
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
reader.onload = () =>
|
||||
resolve(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: dataUrl,
|
||||
mediaType: file.type,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||
const messageIndex = messages.length
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
|
||||
const accessCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
sendMessage(
|
||||
{ parts },
|
||||
{
|
||||
@@ -268,153 +182,29 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
xml: chartXml,
|
||||
sessionId,
|
||||
},
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
setInput("")
|
||||
setFiles([])
|
||||
setInput("");
|
||||
setFiles([]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error)
|
||||
console.error("Error fetching chart data:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setInput(e.target.value)
|
||||
}
|
||||
setInput(e.target.value);
|
||||
};
|
||||
|
||||
const handleFileChange = (newFiles: File[]) => {
|
||||
setFiles(newFiles)
|
||||
}
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const handleRegenerate = async (messageIndex: number) => {
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (isProcessing) return
|
||||
|
||||
// Find the user message before this assistant message
|
||||
let userMessageIndex = messageIndex - 1
|
||||
while (
|
||||
userMessageIndex >= 0 &&
|
||||
messages[userMessageIndex].role !== "user"
|
||||
) {
|
||||
userMessageIndex--
|
||||
}
|
||||
|
||||
if (userMessageIndex < 0) return
|
||||
|
||||
const userMessage = messages[userMessageIndex]
|
||||
const userParts = userMessage.parts
|
||||
|
||||
// Get the text from the user message
|
||||
const textPart = userParts?.find((p: any) => p.type === "text")
|
||||
if (!textPart) return
|
||||
|
||||
// Get the saved XML snapshot for this user message
|
||||
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
|
||||
if (!savedXml) {
|
||||
console.error(
|
||||
"No saved XML snapshot for message index:",
|
||||
userMessageIndex,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the diagram to the saved state
|
||||
onDisplayChart(savedXml)
|
||||
|
||||
// Update ref directly to ensure edit_diagram has the correct XML
|
||||
chartXMLRef.current = savedXml
|
||||
|
||||
// Clean up snapshots for messages after the user message (they will be removed)
|
||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||
if (key > userMessageIndex) {
|
||||
xmlSnapshotsRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
||||
// Use flushSync to ensure state update is processed synchronously before sending
|
||||
const newMessages = messages.slice(0, userMessageIndex)
|
||||
flushSync(() => {
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
sendMessage(
|
||||
{ parts: userParts },
|
||||
{
|
||||
body: {
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (isProcessing) return
|
||||
|
||||
const message = messages[messageIndex]
|
||||
if (!message || message.role !== "user") return
|
||||
|
||||
// Get the saved XML snapshot for this user message
|
||||
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
|
||||
if (!savedXml) {
|
||||
console.error(
|
||||
"No saved XML snapshot for message index:",
|
||||
messageIndex,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the diagram to the saved state
|
||||
onDisplayChart(savedXml)
|
||||
|
||||
// Update ref directly to ensure edit_diagram has the correct XML
|
||||
chartXMLRef.current = savedXml
|
||||
|
||||
// Clean up snapshots for messages after the user message (they will be removed)
|
||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||
if (key > messageIndex) {
|
||||
xmlSnapshotsRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new parts with updated text
|
||||
const newParts = message.parts?.map((part: any) => {
|
||||
if (part.type === "text") {
|
||||
return { ...part, text: newText }
|
||||
}
|
||||
return part
|
||||
}) || [{ type: "text", text: newText }]
|
||||
|
||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
||||
// Use flushSync to ensure state update is processed synchronously before sending
|
||||
const newMessages = messages.slice(0, messageIndex)
|
||||
flushSync(() => {
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
sendMessage(
|
||||
{ parts: newParts },
|
||||
{
|
||||
body: {
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed view (desktop only)
|
||||
if (!isVisible && !isMobile) {
|
||||
// Collapsed view
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
||||
<ButtonWithTooltip
|
||||
@@ -436,55 +226,34 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
AI Chat
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Full view
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
richColors
|
||||
style={{ position: "absolute" }}
|
||||
/>
|
||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
|
||||
{/* Header */}
|
||||
<header
|
||||
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
||||
>
|
||||
<header className="px-5 py-4 border-b border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/favicon.ico"
|
||||
alt="Next AI Drawio"
|
||||
width={isMobile ? 24 : 28}
|
||||
height={isMobile ? 24 : 28}
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded"
|
||||
/>
|
||||
<h1
|
||||
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
|
||||
>
|
||||
<h1 className="text-base font-semibold tracking-tight whitespace-nowrap">
|
||||
Next AI Drawio
|
||||
</h1>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<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."
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-500 hover:text-green-600"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
@@ -493,84 +262,49 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<FaGithub
|
||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||
/>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
{accessCodeRequired && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Settings"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSettingsDialog(true)}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<Settings
|
||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||
/>
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleVisibility}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleVisibility}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages */}
|
||||
<main className="flex-1 w-full overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<ChatMessageDisplay
|
||||
messages={messages}
|
||||
error={error}
|
||||
setInput={setInput}
|
||||
setFiles={handleFileChange}
|
||||
sessionId={sessionId}
|
||||
onRegenerate={handleRegenerate}
|
||||
onEditMessage={handleEditMessage}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Input */}
|
||||
<footer
|
||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||
>
|
||||
<footer className="p-4 border-t border-border/50 bg-card/50">
|
||||
<ChatInput
|
||||
input={input}
|
||||
status={status}
|
||||
onSubmit={onFormSubmit}
|
||||
onChange={handleInputChange}
|
||||
onClearChat={() => {
|
||||
setMessages([])
|
||||
clearDiagram()
|
||||
setSessionId(
|
||||
`session-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`,
|
||||
)
|
||||
xmlSnapshotsRef.current.clear()
|
||||
setMessages([]);
|
||||
clearDiagram();
|
||||
setSessionId(`session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
|
||||
}}
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
sessionId={sessionId}
|
||||
error={error}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<SettingsDialog
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
/>
|
||||
</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 {
|
||||
code: string
|
||||
language?: "xml" | "json"
|
||||
code: string;
|
||||
language?: "xml" | "json";
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
<Highlight theme={themes.github} code={code} language={language}>
|
||||
{({
|
||||
className: _className,
|
||||
style,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
|
||||
style={{
|
||||
...style,
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
fontFamily: "var(--font-mono), ui-monospace, monospace",
|
||||
backgroundColor: "transparent",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
@@ -32,16 +25,9 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
}}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
|
||||
{line.map((token, key) => (
|
||||
<span
|
||||
key={key}
|
||||
{...getTokenProps({ token })}
|
||||
/>
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -49,5 +35,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
)}
|
||||
</Highlight>
|
||||
</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 Image from "next/image"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface FilePreviewListProps {
|
||||
files: File[]
|
||||
onRemoveFile: (fileToRemove: File) => void
|
||||
files: File[];
|
||||
onRemoveFile: (fileToRemove: File) => void;
|
||||
}
|
||||
|
||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
||||
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
// Create and cleanup object URLs when files change
|
||||
// Cleanup object URLs on unmount
|
||||
useEffect(() => {
|
||||
const currentUrls = imageUrlsRef.current
|
||||
const newUrls = new Map<File, string>()
|
||||
const objectUrls = files
|
||||
.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 () => {
|
||||
imageUrlsRef.current.forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
objectUrls.forEach(URL.revokeObjectURL);
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
// Clear selected image if its URL was revoked
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedImage &&
|
||||
!Array.from(imageUrls.values()).includes(selectedImage)
|
||||
) {
|
||||
setSelectedImage(null)
|
||||
}
|
||||
}, [imageUrls, selectedImage])
|
||||
|
||||
if (files.length === 0) return null
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||
{files.map((file, index) => {
|
||||
const imageUrl = imageUrls.get(file) || null
|
||||
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||
return (
|
||||
<div key={file.name + index} className="relative group">
|
||||
<div
|
||||
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
|
||||
onClick={() =>
|
||||
imageUrl && setSelectedImage(imageUrl)
|
||||
}
|
||||
onClick={() => imageUrl && setSelectedImage(imageUrl)}
|
||||
>
|
||||
{file.type.startsWith("image/") && imageUrl ? (
|
||||
{file.type.startsWith("image/") ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
src={imageUrl!}
|
||||
alt={file.name}
|
||||
width={80}
|
||||
height={80}
|
||||
@@ -99,7 +59,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -129,5 +89,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,32 +8,34 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
|
||||
interface HistoryDialogProps {
|
||||
showHistory: boolean
|
||||
onToggleHistory: (show: boolean) => void
|
||||
showHistory: boolean;
|
||||
onToggleHistory: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function HistoryDialog({
|
||||
showHistory,
|
||||
onToggleHistory,
|
||||
}: HistoryDialogProps) {
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIndex(null)
|
||||
onToggleHistory(false)
|
||||
}
|
||||
setSelectedIndex(null);
|
||||
onToggleHistory(false);
|
||||
};
|
||||
|
||||
const handleConfirmRestore = () => {
|
||||
if (selectedIndex !== null) {
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml)
|
||||
handleClose()
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml);
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||
@@ -100,12 +100,15 @@ export function HistoryDialog({
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ResetWarningModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClear: () => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function ResetWarningModal({
|
||||
@@ -44,5 +44,5 @@ export function ResetWarningModal({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
|
||||
const FORMAT_OPTIONS: {
|
||||
value: ExportFormat
|
||||
label: string
|
||||
extension: string
|
||||
}[] = [
|
||||
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
|
||||
{ value: "png", label: "PNG Image", extension: ".png" },
|
||||
{ value: "svg", label: "SVG Image", extension: ".svg" },
|
||||
]
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (filename: string, format: ExportFormat) => void
|
||||
defaultFilename: string
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (filename: string) => void;
|
||||
defaultFilename: string;
|
||||
}
|
||||
|
||||
export function SaveDialog({
|
||||
@@ -43,29 +24,26 @@ export function SaveDialog({
|
||||
onSave,
|
||||
defaultFilename,
|
||||
}: SaveDialogProps) {
|
||||
const [filename, setFilename] = useState(defaultFilename)
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
||||
const [filename, setFilename] = useState(defaultFilename);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFilename(defaultFilename)
|
||||
setFilename(defaultFilename);
|
||||
}
|
||||
}, [open, defaultFilename])
|
||||
}, [open, defaultFilename]);
|
||||
|
||||
const handleSave = () => {
|
||||
const finalFilename = filename.trim() || defaultFilename
|
||||
onSave(finalFilename, format)
|
||||
onOpenChange(false)
|
||||
}
|
||||
const finalFilename = filename.trim() || defaultFilename;
|
||||
onSave(finalFilename);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -73,56 +51,30 @@ export function SaveDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Diagram</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Format</label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as ExportFormat)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Filename</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter filename"
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||
/>
|
||||
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
|
||||
{currentFormat?.extension || ".drawio"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Filename</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter filename"
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||
/>
|
||||
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
|
||||
.drawio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:brightness-75",
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
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",
|
||||
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
|
||||
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}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,90 +1,69 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { extractDiagramXML } from "../lib/utils"
|
||||
import React, { createContext, useContext, useRef, useState } from "react";
|
||||
import type { DrawIoEmbedRef } from "react-drawio";
|
||||
import { extractDiagramXML } from "../lib/utils";
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
loadDiagram: (chart: string) => void
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.Ref<((value: string) => void) | null>
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
) => void
|
||||
chartXML: string;
|
||||
latestSvg: string;
|
||||
diagramHistory: { svg: string; xml: string }[];
|
||||
loadDiagram: (chart: string) => void;
|
||||
handleExport: () => void;
|
||||
handleExportWithoutHistory: () => void;
|
||||
resolverRef: React.Ref<((value: string) => void) | null>;
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||
handleDiagramExport: (data: any) => void;
|
||||
clearDiagram: () => void;
|
||||
saveDiagramToFile: (filename: string) => void;
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
const [chartXML, setChartXML] = useState<string>("");
|
||||
const [latestSvg, setLatestSvg] = useState<string>("");
|
||||
const [diagramHistory, setDiagramHistory] = useState<
|
||||
{ svg: string; xml: string }[]
|
||||
>([])
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
>([]);
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null);
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null);
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
const expectHistoryExportRef = useRef<boolean>(false);
|
||||
// Track if we're expecting an export for file save
|
||||
const saveResolverRef = useRef<((xml: string) => void) | null>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true
|
||||
expectHistoryExportRef.current = true;
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadDiagram = (chart: string) => {
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
xml: chart,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
if (format === "png" || format === "svg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
const extractedXML = extractDiagramXML(data.data);
|
||||
setChartXML(extractedXML);
|
||||
setLatestSvg(data.data);
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
if (expectHistoryExportRef.current) {
|
||||
@@ -94,117 +73,58 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
])
|
||||
expectHistoryExportRef.current = false
|
||||
]);
|
||||
expectHistoryExportRef.current = false;
|
||||
}
|
||||
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML)
|
||||
resolverRef.current = null
|
||||
resolverRef.current(extractedXML);
|
||||
resolverRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle save to file if requested
|
||||
if (saveResolverRef.current) {
|
||||
saveResolverRef.current(extractedXML);
|
||||
saveResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
loadDiagram(emptyDiagram)
|
||||
setChartXML(emptyDiagram)
|
||||
setLatestSvg("")
|
||||
setDiagramHistory([])
|
||||
}
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`;
|
||||
loadDiagram(emptyDiagram);
|
||||
setChartXML(emptyDiagram);
|
||||
setLatestSvg("");
|
||||
setDiagramHistory([]);
|
||||
};
|
||||
|
||||
const saveDiagramToFile = (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
) => {
|
||||
const saveDiagramToFile = (filename: string) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready")
|
||||
return
|
||||
console.warn("Draw.io editor not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||
// Export diagram and save when export completes
|
||||
drawioRef.current.exportDiagram({ format: "xmlsvg" });
|
||||
saveResolverRef.current = (xml: string) => {
|
||||
// Wrap in proper .drawio format
|
||||
let fileContent = xml;
|
||||
if (!xml.includes("<mxfile")) {
|
||||
fileContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`;
|
||||
}
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else {
|
||||
// SVG format
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
},
|
||||
format,
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
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)
|
||||
}
|
||||
}
|
||||
const blob = new Blob([fileContent], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// Add .drawio extension if not present
|
||||
a.download = filename.endsWith(".drawio") ? filename : `${filename}.drawio`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Delay URL revocation to ensure download completes
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<DiagramContext.Provider
|
||||
@@ -224,13 +144,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</DiagramContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useDiagram() {
|
||||
const context = useContext(DiagramContext)
|
||||
const context = useContext(DiagramContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDiagram must be used within a DiagramProvider")
|
||||
throw new Error("useDiagram must be used within a DiagramProvider");
|
||||
}
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +47,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# 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 +1,22 @@
|
||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
|
||||
export function register() {
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn(
|
||||
"[Langfuse] Environment variables not configured - telemetry disabled",
|
||||
)
|
||||
return
|
||||
}
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn('[Langfuse] Environment variables not configured - telemetry disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const langfuseSpanProcessor = new LangfuseSpanProcessor({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (
|
||||
spanName.startsWith("POST /") ||
|
||||
spanName.startsWith("GET /") ||
|
||||
spanName.includes("BaseServer") ||
|
||||
spanName.includes("handleRequest")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
const langfuseSpanProcessor = new LangfuseSpanProcessor({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
});
|
||||
|
||||
const tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [langfuseSpanProcessor],
|
||||
})
|
||||
const tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [langfuseSpanProcessor],
|
||||
});
|
||||
|
||||
// Register globally so AI SDK's telemetry also uses this processor
|
||||
tracerProvider.register()
|
||||
tracerProvider.register();
|
||||
}
|
||||
|
||||
@@ -1,89 +1,62 @@
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { azure, createAzure } from "@ai-sdk/azure"
|
||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||
import { bedrock } from '@ai-sdk/amazon-bedrock';
|
||||
import { openai, createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { azure, createAzure } from '@ai-sdk/azure';
|
||||
import { ollama, createOllama } from 'ollama-ai-provider-v2';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { deepseek, createDeepSeek } from '@ai-sdk/deepseek';
|
||||
|
||||
export type ProviderName =
|
||||
| "bedrock"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "azure"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "deepseek"
|
||||
| 'bedrock'
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'azure'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'deepseek';
|
||||
|
||||
interface ModelConfig {
|
||||
model: any
|
||||
providerOptions?: any
|
||||
headers?: Record<string, string>
|
||||
modelId: string
|
||||
model: any;
|
||||
providerOptions?: any;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
const BEDROCK_ANTHROPIC_BETA = {
|
||||
bedrock: {
|
||||
anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"],
|
||||
},
|
||||
}
|
||||
bedrock: {
|
||||
anthropicBeta: ['fine-grained-tool-streaming-2025-05-14'],
|
||||
},
|
||||
};
|
||||
|
||||
// Direct Anthropic API headers for beta features
|
||||
const ANTHROPIC_BETA_HEADERS = {
|
||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable
|
||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
azure: "AZURE_API_KEY",
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect provider based on available API keys
|
||||
* Returns the provider if exactly one is configured, otherwise null
|
||||
*/
|
||||
function detectProvider(): ProviderName | null {
|
||||
const configuredProviders: ProviderName[] = []
|
||||
|
||||
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
|
||||
if (envVar === null) {
|
||||
// Skip ollama - it doesn't require credentials
|
||||
continue
|
||||
}
|
||||
if (process.env[envVar]) {
|
||||
configuredProviders.push(provider as ProviderName)
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredProviders.length === 1) {
|
||||
return configuredProviders[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14',
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that required API keys are present for the selected provider
|
||||
*/
|
||||
function validateProviderCredentials(provider: ProviderName): void {
|
||||
const requiredVar = PROVIDER_ENV_VARS[provider]
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`,
|
||||
)
|
||||
}
|
||||
const requiredEnvVars: Record<ProviderName, string | null> = {
|
||||
bedrock: 'AWS_ACCESS_KEY_ID',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
azure: 'AZURE_API_KEY',
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
deepseek: 'DEEPSEEK_API_KEY',
|
||||
};
|
||||
|
||||
const requiredVar = requiredEnvVars[provider];
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,164 +79,122 @@ function validateProviderCredentials(provider: ProviderName): void {
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
*/
|
||||
export function getAIModel(): ModelConfig {
|
||||
const modelId = process.env.AI_MODEL
|
||||
const provider = (process.env.AI_PROVIDER || 'bedrock') as ProviderName;
|
||||
const modelId = process.env.AI_MODEL;
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error(
|
||||
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
|
||||
)
|
||||
}
|
||||
if (!modelId) {
|
||||
throw new Error(
|
||||
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`
|
||||
);
|
||||
}
|
||||
|
||||
// Determine provider: explicit config > auto-detect > error
|
||||
let provider: ProviderName
|
||||
if (process.env.AI_PROVIDER) {
|
||||
provider = process.env.AI_PROVIDER as ProviderName
|
||||
} else {
|
||||
const detected = detectProvider()
|
||||
if (detected) {
|
||||
provider = detected
|
||||
console.log(`[AI Provider] Auto-detected provider: ${provider}`)
|
||||
} else {
|
||||
// List configured providers for better error message
|
||||
const configured = Object.entries(PROVIDER_ENV_VARS)
|
||||
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
||||
.map(([p]) => p)
|
||||
// Validate provider credentials
|
||||
validateProviderCredentials(provider);
|
||||
|
||||
if (configured.length === 0) {
|
||||
throw new Error(
|
||||
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
|
||||
`- DEEPSEEK_API_KEY for DeepSeek\n` +
|
||||
`- OPENAI_API_KEY for OpenAI\n` +
|
||||
`- ANTHROPIC_API_KEY for Anthropic\n` +
|
||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple AI providers configured (${configured.join(", ")}). ` +
|
||||
`Please set AI_PROVIDER to specify which one to use.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Log initialization for debugging
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`);
|
||||
|
||||
// Validate provider credentials
|
||||
validateProviderCredentials(provider)
|
||||
let model: any;
|
||||
let providerOptions: any = undefined;
|
||||
let headers: Record<string, string> | undefined = undefined;
|
||||
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||
switch (provider) {
|
||||
case 'bedrock':
|
||||
model = bedrock(modelId);
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes('anthropic.claude')) {
|
||||
providerOptions = BEDROCK_ANTHROPIC_BETA;
|
||||
}
|
||||
break;
|
||||
|
||||
let model: any
|
||||
let providerOptions: any
|
||||
let headers: Record<string, string> | undefined
|
||||
case 'openai':
|
||||
if (process.env.OPENAI_BASE_URL) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
});
|
||||
model = customOpenAI.chat(modelId);
|
||||
} else {
|
||||
model = openai(modelId);
|
||||
}
|
||||
break;
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || "us-west-2",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
providerOptions = BEDROCK_ANTHROPIC_BETA
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'anthropic':
|
||||
const customProvider = createAnthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
});
|
||||
model = customProvider(modelId);
|
||||
// Add beta headers for fine-grained tool streaming
|
||||
headers = ANTHROPIC_BETA_HEADERS;
|
||||
break;
|
||||
|
||||
case "openai":
|
||||
if (process.env.OPENAI_BASE_URL) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
})
|
||||
model = customOpenAI.chat(modelId)
|
||||
} else {
|
||||
model = openai(modelId)
|
||||
}
|
||||
break
|
||||
case 'google':
|
||||
if (process.env.GOOGLE_BASE_URL) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
baseURL: process.env.GOOGLE_BASE_URL,
|
||||
});
|
||||
model = customGoogle(modelId);
|
||||
} else {
|
||||
model = google(modelId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "anthropic": {
|
||||
const customProvider = createAnthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
baseURL:
|
||||
process.env.ANTHROPIC_BASE_URL ||
|
||||
"https://api.anthropic.com/v1",
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
})
|
||||
model = customProvider(modelId)
|
||||
// Add beta headers for fine-grained tool streaming
|
||||
headers = ANTHROPIC_BETA_HEADERS
|
||||
break
|
||||
}
|
||||
case 'azure':
|
||||
if (process.env.AZURE_BASE_URL) {
|
||||
const customAzure = createAzure({
|
||||
apiKey: process.env.AZURE_API_KEY,
|
||||
baseURL: process.env.AZURE_BASE_URL,
|
||||
});
|
||||
model = customAzure(modelId);
|
||||
} else {
|
||||
model = azure(modelId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "google":
|
||||
if (process.env.GOOGLE_BASE_URL) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
baseURL: process.env.GOOGLE_BASE_URL,
|
||||
})
|
||||
model = customGoogle(modelId)
|
||||
} else {
|
||||
model = google(modelId)
|
||||
}
|
||||
break
|
||||
case 'ollama':
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
const customOllama = createOllama({
|
||||
baseURL: process.env.OLLAMA_BASE_URL,
|
||||
});
|
||||
model = customOllama(modelId);
|
||||
} else {
|
||||
model = ollama(modelId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "azure":
|
||||
if (process.env.AZURE_BASE_URL) {
|
||||
const customAzure = createAzure({
|
||||
apiKey: process.env.AZURE_API_KEY,
|
||||
baseURL: process.env.AZURE_BASE_URL,
|
||||
})
|
||||
model = customAzure(modelId)
|
||||
} else {
|
||||
model = azure(modelId)
|
||||
}
|
||||
break
|
||||
case 'openrouter':
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }),
|
||||
});
|
||||
model = openrouter(modelId);
|
||||
break;
|
||||
|
||||
case "ollama":
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
const customOllama = createOllama({
|
||||
baseURL: process.env.OLLAMA_BASE_URL,
|
||||
})
|
||||
model = customOllama(modelId)
|
||||
} else {
|
||||
model = ollama(modelId)
|
||||
}
|
||||
break
|
||||
case 'deepseek':
|
||||
if (process.env.DEEPSEEK_BASE_URL) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
});
|
||||
model = customDeepSeek(modelId);
|
||||
} else {
|
||||
model = deepseek(modelId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
...(process.env.OPENROUTER_BASE_URL && {
|
||||
baseURL: process.env.OPENROUTER_BASE_URL,
|
||||
}),
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`
|
||||
);
|
||||
}
|
||||
|
||||
case "deepseek":
|
||||
if (process.env.DEEPSEEK_BASE_URL) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
})
|
||||
model = customDeepSeek(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
// Log if provider options or headers are being applied
|
||||
if (providerOptions || headers) {
|
||||
console.log('[AI Provider] Applying provider-specific options/headers');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
|
||||
)
|
||||
}
|
||||
|
||||
return { model, providerOptions, headers, modelId }
|
||||
return { model, providerOptions, headers };
|
||||
}
|
||||
|
||||
@@ -1,129 +1,128 @@
|
||||
export interface CachedResponse {
|
||||
promptText: string
|
||||
hasImage: boolean
|
||||
xml: string
|
||||
promptText: string;
|
||||
hasImage: boolean;
|
||||
xml: string;
|
||||
}
|
||||
|
||||
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
{
|
||||
promptText:
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
{
|
||||
promptText: "Give me a **animated connector** diagram of transformer's architecture",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="20" width="250" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Input Embedding (Left - Encoder Side) -->
|
||||
<mxCell id="input_embed" value="Input Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="480" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Positional Encoding (Left) -->
|
||||
<mxCell id="pos_enc_left" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="420" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Encoder Stack -->
|
||||
<mxCell id="encoder_box" value="ENCODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="180" width="160" height="220" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Multi-Head Attention (Encoder) -->
|
||||
<mxCell id="mha_enc" value="Multi-Head
Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="330" width="120" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Add & Norm 1 (Encoder) -->
|
||||
<mxCell id="add_norm1_enc" value="Add & Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="280" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Feed Forward (Encoder) -->
|
||||
<mxCell id="ff_enc" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="240" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Add & Norm 2 (Encoder) -->
|
||||
<mxCell id="add_norm2_enc" value="Add & Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="200" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Nx label for encoder -->
|
||||
<mxCell id="nx_enc" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="30" y="275" width="30" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Output Embedding (Right - Decoder Side) -->
|
||||
<mxCell id="output_embed" value="Output Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="480" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Positional Encoding (Right) -->
|
||||
<mxCell id="pos_enc_right" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="420" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Decoder Stack -->
|
||||
<mxCell id="decoder_box" value="DECODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="140" width="160" height="260" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Masked Multi-Head Attention (Decoder) -->
|
||||
<mxCell id="masked_mha_dec" value="Masked Multi-Head
Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="340" width="120" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Add & Norm 1 (Decoder) -->
|
||||
<mxCell id="add_norm1_dec" value="Add & Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="290" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Multi-Head Attention (Decoder - Cross Attention) -->
|
||||
<mxCell id="mha_dec" value="Multi-Head
Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="240" width="120" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Add & Norm 2 (Decoder) -->
|
||||
<mxCell id="add_norm2_dec" value="Add & Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="200" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Feed Forward (Decoder) -->
|
||||
<mxCell id="ff_dec" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="160" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Add & Norm 3 (Decoder) -->
|
||||
<mxCell id="add_norm3_dec" value="Add & Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="120" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Nx label for decoder -->
|
||||
<mxCell id="nx_dec" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="790" y="255" width="30" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Linear -->
|
||||
<mxCell id="linear" value="Linear" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="80" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Softmax -->
|
||||
<mxCell id="softmax" value="Softmax" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="40" width="120" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Output Probabilities -->
|
||||
<mxCell id="output" value="Output Probabilities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="0" width="140" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Animated Connectors - Encoder Side -->
|
||||
<mxCell id="conn1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="input_embed" target="pos_enc_left">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
@@ -144,7 +143,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Encoder to Decoder Cross Attention -->
|
||||
<mxCell id="conn_cross" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;strokeColor=#9673a6;flowAnimation=1;dashed=1;" edge="1" parent="1" source="add_norm2_enc" target="mha_dec">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
@@ -159,7 +158,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Animated Connectors - Decoder Side -->
|
||||
<mxCell id="conn6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;" edge="1" parent="1" source="output_embed" target="pos_enc_right">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
@@ -200,7 +199,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Residual Connections (Encoder) -->
|
||||
<mxCell id="res1_enc" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="mha_enc" target="add_norm1_enc">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
@@ -219,7 +218,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Residual Connections (Decoder) -->
|
||||
<mxCell id="res1_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="masked_mha_dec" target="add_norm1_dec">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
@@ -247,7 +246,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Input/Output Labels -->
|
||||
<mxCell id="input_label" value="Inputs" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="110" y="530" width="60" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
@@ -256,45 +255,45 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this in aws style",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this in aws style",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
|
||||
<!-- AWS Cloud Container -->
|
||||
<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
||||
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- User -->
|
||||
<mxCell id="3" value="User" style="sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;fillColor=#232F3D;strokeColor=none;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;pointerEvents=1;shape=mxgraph.aws4.user;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="240" width="78" height="78" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- EC2 Instance -->
|
||||
<mxCell id="4" value="EC2" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#ED7100;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="560" y="240" width="78" height="78" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- S3 Bucket -->
|
||||
<mxCell id="5" value="S3" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#7AA116;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.s3;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="960" y="120" width="78" height="78" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Bedrock -->
|
||||
<mxCell id="6" value="bedrock" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#01A88D;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.bedrock;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="960" y="260" width="78" height="78" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- DynamoDB -->
|
||||
<mxCell id="7" value="DynamoDB" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#C925D1;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.dynamodb;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="960" y="400" width="78" height="78" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow: User to EC2 -->
|
||||
<mxCell id="8" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="400" y="350" as="sourcePoint"/>
|
||||
@@ -302,7 +301,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow: EC2 to S3 -->
|
||||
<mxCell id="9" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.25;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="700" y="350" as="sourcePoint"/>
|
||||
@@ -310,7 +309,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow: EC2 to Bedrock -->
|
||||
<mxCell id="10" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="700" y="350" as="sourcePoint"/>
|
||||
@@ -318,7 +317,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow: EC2 to DynamoDB -->
|
||||
<mxCell id="11" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.75;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="7">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="700" y="350" as="sourcePoint"/>
|
||||
@@ -326,122 +325,122 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this flowchart.",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this flowchart.",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
|
||||
<!-- Start: Lamp doesn't work -->
|
||||
<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow from start to first decision -->
|
||||
<mxCell id="3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Decision: Lamp plugged in? -->
|
||||
<mxCell id="4" value="Lamp<br>plugged in?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="130" y="150" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow to Plug in lamp (No) -->
|
||||
<mxCell id="5" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry x="-0.2" relative="1" as="geometry">
|
||||
<mxPoint as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Action: Plug in lamp -->
|
||||
<mxCell id="6" value="Plug in lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="220" width="200" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow down to second decision (Yes) -->
|
||||
<mxCell id="7" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Decision: Bulb burned out? -->
|
||||
<mxCell id="8" value="Bulb<br>burned out?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="130" y="400" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow to Replace bulb (Yes) -->
|
||||
<mxCell id="9" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="10">
|
||||
<mxGeometry x="-0.2" relative="1" as="geometry">
|
||||
<mxPoint as="offset"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Action: Replace bulb -->
|
||||
<mxCell id="10" value="Replace bulb" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="470" width="200" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Arrow down to Repair lamp (No) -->
|
||||
<mxCell id="11" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Action: Repair lamp -->
|
||||
<mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Draw a cat for me",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Draw a cat for me",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
|
||||
<!-- Cat's head -->
|
||||
<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left ear -->
|
||||
<mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="120" width="50" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right ear -->
|
||||
<mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1">
|
||||
<mxGeometry x="390" y="120" width="50" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left ear inner -->
|
||||
<mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="290" y="135" width="30" height="35" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right ear inner -->
|
||||
<mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1">
|
||||
<mxGeometry x="400" y="135" width="30" height="35" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left eye -->
|
||||
<mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
|
||||
<mxGeometry x="325" y="185" width="15" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right eye -->
|
||||
<mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
|
||||
<mxGeometry x="380" y="185" width="15" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Nose -->
|
||||
<mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1">
|
||||
<mxGeometry x="350" y="210" width="20" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Mouth left -->
|
||||
<mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="360" y="220" as="sourcePoint"/>
|
||||
@@ -452,7 +451,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Mouth right -->
|
||||
<mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="360" y="220" as="sourcePoint"/>
|
||||
@@ -463,7 +462,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left whisker 1 -->
|
||||
<mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="310" y="200" as="sourcePoint"/>
|
||||
@@ -471,7 +470,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left whisker 2 -->
|
||||
<mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="310" y="210" as="sourcePoint"/>
|
||||
@@ -479,7 +478,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left whisker 3 -->
|
||||
<mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="310" y="220" as="sourcePoint"/>
|
||||
@@ -487,7 +486,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right whisker 1 -->
|
||||
<mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="410" y="200" as="sourcePoint"/>
|
||||
@@ -495,7 +494,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right whisker 2 -->
|
||||
<mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="410" y="210" as="sourcePoint"/>
|
||||
@@ -503,7 +502,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right whisker 3 -->
|
||||
<mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="410" y="220" as="sourcePoint"/>
|
||||
@@ -511,27 +510,27 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Body -->
|
||||
<mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="285" y="250" width="150" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Belly -->
|
||||
<mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="315" y="280" width="90" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Left front paw -->
|
||||
<mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="410" width="40" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Right front paw -->
|
||||
<mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="380" y="410" width="40" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
|
||||
<!-- Tail -->
|
||||
<mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="285" y="340" as="sourcePoint"/>
|
||||
@@ -545,17 +544,14 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxCell>
|
||||
|
||||
</root>`,
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export function findCachedResponse(
|
||||
promptText: string,
|
||||
hasImage: boolean,
|
||||
promptText: string,
|
||||
hasImage: boolean
|
||||
): CachedResponse | undefined {
|
||||
return CACHED_EXAMPLE_RESPONSES.find(
|
||||
(c) =>
|
||||
c.promptText === promptText &&
|
||||
c.hasImage === hasImage &&
|
||||
c.xml !== "",
|
||||
)
|
||||
return CACHED_EXAMPLE_RESPONSES.find(
|
||||
(c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== ''
|
||||
);
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* System prompts for different AI models
|
||||
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
|
||||
*/
|
||||
|
||||
// Default system prompt (~1400 tokens) - works with all models
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see the image that user uploaded.
|
||||
|
||||
## 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:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
description: 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
|
||||
}
|
||||
---Tool2---
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
edits: Array<{search: string, replace: string}>
|
||||
}
|
||||
---End of tools---
|
||||
|
||||
IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
|
||||
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
|
||||
- Adapt artistic concepts into abstract diagram representations using available shapes
|
||||
- Optimize element positioning to prevent overlapping and maintain readability
|
||||
- Structure complex systems into clear, organized visual components
|
||||
|
||||
Layout constraints:
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||
|
||||
Note that:
|
||||
- Use proper tool calls to generate or edit diagrams;
|
||||
- never return raw XML in text responses,
|
||||
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
|
||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||
|
||||
When using edit_diagram tool:
|
||||
- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters!
|
||||
- Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...}
|
||||
- Include complete elements (mxCell + mxGeometry) for reliable matching
|
||||
- Preserve exact whitespace, indentation, and line breaks
|
||||
- BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements
|
||||
- GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"}
|
||||
- For multiple changes, use separate edits in array
|
||||
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
Basic structure:
|
||||
\`\`\`xml
|
||||
<mxGraphModel>
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
\`\`\`
|
||||
Note: All other mxCell elements go as siblings after id="1".
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
|
||||
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
|
||||
3. Use unique sequential IDs for all cells (start from "2" for user content)
|
||||
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
|
||||
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>
|
||||
\`\`\`
|
||||
|
||||
Connector (edge) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
`
|
||||
|
||||
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
|
||||
const EXTENDED_ADDITIONS = `
|
||||
|
||||
## Extended Tool Reference
|
||||
|
||||
### display_diagram Details
|
||||
|
||||
**VALIDATION RULES** (XML will be rejected if violated):
|
||||
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
|
||||
2. Every mxCell needs a unique id attribute
|
||||
3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
|
||||
4. Edge source/target attributes must reference existing cell IDs
|
||||
5. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||
6. Always start with the two root cells: <mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
|
||||
**Example with swimlanes and edges** (note: all mxCells are siblings under <root>):
|
||||
\`\`\`xml
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
\`\`\`
|
||||
|
||||
### edit_diagram Details
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
||||
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly
|
||||
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
|
||||
- Break large changes into multiple smaller edits
|
||||
- Each search must contain complete lines (never truncate mid-line)
|
||||
- First match only - be specific enough to target the right element
|
||||
|
||||
**Input Format:**
|
||||
\`\`\`json
|
||||
{
|
||||
"edits": [
|
||||
{
|
||||
"search": "EXACT lines copied from current XML (preserve attribute order!)",
|
||||
"replace": "Replacement lines"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## edit_diagram Best Practices
|
||||
|
||||
### Core Principle: Unique & Precise Patterns
|
||||
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
|
||||
1. Review the "Current diagram XML" in the system context
|
||||
2. Identify the exact element(s) to modify by their unique id attribute
|
||||
3. Include enough context to ensure uniqueness
|
||||
|
||||
### Pattern Construction Rules
|
||||
|
||||
**Rule 1: Always include the element's id attribute**
|
||||
\`\`\`json
|
||||
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
|
||||
\`\`\`
|
||||
|
||||
**Rule 2: Include complete XML elements when possible**
|
||||
\`\`\`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>",
|
||||
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**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.
|
||||
|
||||
### Good vs Bad Patterns
|
||||
|
||||
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
|
||||
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
|
||||
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
|
||||
|
||||
### Error Recovery
|
||||
If edit_diagram fails with "pattern not found":
|
||||
1. **First retry**: Check attribute order - copy EXACTLY from current XML
|
||||
2. **Second retry**: Expand context - include more surrounding lines
|
||||
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
|
||||
|
||||
## Common Style Properties
|
||||
|
||||
### Shape Styles
|
||||
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
|
||||
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
|
||||
|
||||
### Edge/Connector Styles
|
||||
- endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
|
||||
- curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
|
||||
- dashed=1, dashPattern=3 3, flowAnimation=1
|
||||
|
||||
### Text Styles
|
||||
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic)
|
||||
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
|
||||
|
||||
## Common Shape Types
|
||||
|
||||
### Basic Shapes
|
||||
- Rectangle: rounded=0;whiteSpace=wrap;html=1;
|
||||
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1;
|
||||
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
|
||||
- Diamond: rhombus;whiteSpace=wrap;html=1;
|
||||
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
|
||||
|
||||
### Flowchart Shapes
|
||||
- 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
|
||||
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
|
||||
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
## Example: Complete Flowchart
|
||||
|
||||
\`\`\`xml
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||
|
||||
// Model patterns that require extended prompt (4000 token cache minimum)
|
||||
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
|
||||
const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
||||
"claude-opus-4-5", // Matches any Opus 4.5 variant
|
||||
"claude-haiku-4-5", // Matches any Haiku 4.5 variant
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the appropriate system prompt based on the model ID
|
||||
* Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum
|
||||
* @param modelId - The AI model ID from environment
|
||||
* @returns The system prompt string
|
||||
*/
|
||||
export function getSystemPrompt(modelId?: string): string {
|
||||
const modelName = modelId || "AI"
|
||||
|
||||
let prompt: string
|
||||
if (
|
||||
modelId &&
|
||||
EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>
|
||||
modelId.includes(pattern),
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
|
||||
)
|
||||
prompt = EXTENDED_SYSTEM_PROMPT
|
||||
} else {
|
||||
console.log(
|
||||
`[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`,
|
||||
)
|
||||
prompt = DEFAULT_SYSTEM_PROMPT
|
||||
}
|
||||
|
||||
return prompt.replace("{{MODEL_NAME}}", modelName)
|
||||
}
|
||||
912
lib/utils.ts
912
lib/utils.ts
@@ -1,9 +1,9 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import * as pako from "pako"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import * as pako from 'pako';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,48 +12,48 @@ export function cn(...inputs: ClassValue[]) {
|
||||
* @param indent - The indentation string (default: ' ')
|
||||
* @returns Formatted XML string
|
||||
*/
|
||||
export function formatXML(xml: string, indent: string = " "): string {
|
||||
let formatted = ""
|
||||
let pad = 0
|
||||
export function formatXML(xml: string, indent: string = ' '): string {
|
||||
let formatted = '';
|
||||
let pad = 0;
|
||||
|
||||
// Remove existing whitespace between tags
|
||||
xml = xml.replace(/>\s*</g, "><").trim()
|
||||
// Remove existing whitespace between tags
|
||||
xml = xml.replace(/>\s*</g, '><').trim();
|
||||
|
||||
// Split on tags
|
||||
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean)
|
||||
// Split on tags
|
||||
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean);
|
||||
|
||||
tags.forEach((node) => {
|
||||
if (node.match(/^<\/\w/)) {
|
||||
// Closing tag - decrease indent
|
||||
pad = Math.max(0, pad - 1)
|
||||
formatted += indent.repeat(pad) + node + "\n"
|
||||
} else if (node.match(/^<\w[^>]*[^/]>.*$/)) {
|
||||
// Opening tag
|
||||
formatted += indent.repeat(pad) + node
|
||||
// Only add newline if next item is a tag
|
||||
const nextIndex = tags.indexOf(node) + 1
|
||||
if (nextIndex < tags.length && tags[nextIndex].startsWith("<")) {
|
||||
formatted += "\n"
|
||||
if (!node.match(/^<\w[^>]*\/>$/)) {
|
||||
pad++
|
||||
}
|
||||
}
|
||||
} else if (node.match(/^<\w[^>]*\/>$/)) {
|
||||
// Self-closing tag
|
||||
formatted += indent.repeat(pad) + node + "\n"
|
||||
} else if (node.startsWith("<")) {
|
||||
// Other tags (like <?xml)
|
||||
formatted += indent.repeat(pad) + node + "\n"
|
||||
} else {
|
||||
// Text content
|
||||
formatted += node
|
||||
tags.forEach((node) => {
|
||||
if (node.match(/^<\/\w/)) {
|
||||
// Closing tag - decrease indent
|
||||
pad = Math.max(0, pad - 1);
|
||||
formatted += indent.repeat(pad) + node + '\n';
|
||||
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||||
// Opening tag
|
||||
formatted += indent.repeat(pad) + node;
|
||||
// Only add newline if next item is a tag
|
||||
const nextIndex = tags.indexOf(node) + 1;
|
||||
if (nextIndex < tags.length && tags[nextIndex].startsWith('<')) {
|
||||
formatted += '\n';
|
||||
if (!node.match(/^<\w[^>]*\/>$/)) {
|
||||
pad++;
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (node.match(/^<\w[^>]*\/>$/)) {
|
||||
// Self-closing tag
|
||||
formatted += indent.repeat(pad) + node + '\n';
|
||||
} else if (node.startsWith('<')) {
|
||||
// Other tags (like <?xml)
|
||||
formatted += indent.repeat(pad) + node + '\n';
|
||||
} else {
|
||||
// Text content
|
||||
formatted += node;
|
||||
}
|
||||
});
|
||||
|
||||
return formatted.trim()
|
||||
return formatted.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.
|
||||
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
|
||||
* it removes that tag from the output.
|
||||
@@ -61,26 +61,24 @@ export function formatXML(xml: string, indent: string = " "): string {
|
||||
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.
|
||||
*/
|
||||
export function convertToLegalXml(xmlString: string): string {
|
||||
// This regex will match either self-closing <mxCell .../> or a block element
|
||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
||||
let match: RegExpExecArray | null
|
||||
let result = "<root>\n"
|
||||
// This regex will match either self-closing <mxCell .../> or a block element
|
||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let result = "<root>\n";
|
||||
|
||||
while ((match = regex.exec(xmlString)) !== null) {
|
||||
// match[0] contains the entire matched mxCell block
|
||||
// Indent each line of the matched block for readability.
|
||||
const formatted = match[0]
|
||||
.split("\n")
|
||||
.map((line) => " " + line.trim())
|
||||
.join("\n")
|
||||
result += formatted + "\n"
|
||||
}
|
||||
result += "</root>"
|
||||
while ((match = regex.exec(xmlString)) !== null) {
|
||||
// match[0] contains the entire matched mxCell block
|
||||
// Indent each line of the matched block for readability.
|
||||
const formatted = match[0].split('\n').map(line => " " + line.trim()).join('\n');
|
||||
result += formatted + "\n";
|
||||
}
|
||||
result += "</root>";
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace nodes in a Draw.io XML diagram
|
||||
* @param currentXML - The original Draw.io XML string
|
||||
@@ -88,130 +86,94 @@ export function convertToLegalXml(xmlString: string): string {
|
||||
* @returns The updated XML string with replaced nodes
|
||||
*/
|
||||
export function replaceNodes(currentXML: string, nodes: string): string {
|
||||
// Check for valid inputs
|
||||
if (!currentXML || !nodes) {
|
||||
throw new Error("Both currentXML and nodes must be provided")
|
||||
// Check for valid inputs
|
||||
if (!currentXML || !nodes) {
|
||||
throw new Error("Both currentXML and nodes must be provided");
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the XML strings to create DOM objects
|
||||
const parser = new DOMParser();
|
||||
const currentDoc = parser.parseFromString(currentXML, "text/xml");
|
||||
|
||||
// Handle nodes input - if it doesn't contain <root>, wrap it
|
||||
let nodesString = nodes;
|
||||
if (!nodes.includes("<root>")) {
|
||||
nodesString = `<root>${nodes}</root>`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the XML strings to create DOM objects
|
||||
const parser = new DOMParser()
|
||||
const currentDoc = parser.parseFromString(currentXML, "text/xml")
|
||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml");
|
||||
|
||||
// Handle nodes input - if it doesn't contain <root>, wrap it
|
||||
let nodesString = nodes
|
||||
if (!nodes.includes("<root>")) {
|
||||
nodesString = `<root>${nodes}</root>`
|
||||
}
|
||||
// Find the root element in the current document
|
||||
let currentRoot = currentDoc.querySelector("mxGraphModel > root");
|
||||
if (!currentRoot) {
|
||||
// If no root element is found, create the proper structure
|
||||
const mxGraphModel = currentDoc.querySelector("mxGraphModel") ||
|
||||
currentDoc.createElement("mxGraphModel");
|
||||
|
||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
|
||||
if (!currentDoc.contains(mxGraphModel)) {
|
||||
currentDoc.appendChild(mxGraphModel);
|
||||
}
|
||||
|
||||
// Find the root element in the current document
|
||||
let currentRoot = currentDoc.querySelector("mxGraphModel > root")
|
||||
if (!currentRoot) {
|
||||
// If no root element is found, create the proper structure
|
||||
const mxGraphModel =
|
||||
currentDoc.querySelector("mxGraphModel") ||
|
||||
currentDoc.createElement("mxGraphModel")
|
||||
|
||||
if (!currentDoc.contains(mxGraphModel)) {
|
||||
currentDoc.appendChild(mxGraphModel)
|
||||
}
|
||||
|
||||
currentRoot = currentDoc.createElement("root")
|
||||
mxGraphModel.appendChild(currentRoot)
|
||||
}
|
||||
|
||||
// Find the root element in the nodes document
|
||||
const nodesRoot = nodesDoc.querySelector("root")
|
||||
if (!nodesRoot) {
|
||||
throw new Error(
|
||||
"Invalid nodes: Could not find or create <root> element",
|
||||
)
|
||||
}
|
||||
|
||||
// Clear all existing child elements from the current root
|
||||
while (currentRoot.firstChild) {
|
||||
currentRoot.removeChild(currentRoot.firstChild)
|
||||
}
|
||||
|
||||
// Ensure the base cells exist
|
||||
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "0",
|
||||
)
|
||||
|
||||
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
||||
(node) =>
|
||||
node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "1",
|
||||
)
|
||||
|
||||
// Copy all child nodes from the nodes root to the current root
|
||||
Array.from(nodesRoot.childNodes).forEach((node) => {
|
||||
const importedNode = currentDoc.importNode(node, true)
|
||||
currentRoot.appendChild(importedNode)
|
||||
})
|
||||
|
||||
// Add default cells if they don't exist
|
||||
if (!hasCell0) {
|
||||
const cell0 = currentDoc.createElement("mxCell")
|
||||
cell0.setAttribute("id", "0")
|
||||
currentRoot.insertBefore(cell0, currentRoot.firstChild)
|
||||
}
|
||||
|
||||
if (!hasCell1) {
|
||||
const cell1 = currentDoc.createElement("mxCell")
|
||||
cell1.setAttribute("id", "1")
|
||||
cell1.setAttribute("parent", "0")
|
||||
|
||||
// Insert after cell0 if possible
|
||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
||||
if (cell0?.nextSibling) {
|
||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
||||
} else {
|
||||
currentRoot.appendChild(cell1)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the modified DOM back to a string
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(currentDoc)
|
||||
} catch (error) {
|
||||
throw new Error(`Error replacing nodes: ${error}`)
|
||||
currentRoot = currentDoc.createElement("root");
|
||||
mxGraphModel.appendChild(currentRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a character count dictionary from a string
|
||||
* Used for attribute-order agnostic comparison
|
||||
*/
|
||||
function charCountDict(str: string): Map<string, number> {
|
||||
const dict = new Map<string, number>()
|
||||
for (const char of str) {
|
||||
dict.set(char, (dict.get(char) || 0) + 1)
|
||||
// Find the root element in the nodes document
|
||||
const nodesRoot = nodesDoc.querySelector("root");
|
||||
if (!nodesRoot) {
|
||||
throw new Error("Invalid nodes: Could not find or create <root> element");
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two strings by character frequency (order-agnostic)
|
||||
*/
|
||||
function sameCharFrequency(a: string, b: string): boolean {
|
||||
const trimmedA = a.trim()
|
||||
const trimmedB = b.trim()
|
||||
if (trimmedA.length !== trimmedB.length) return false
|
||||
|
||||
const dictA = charCountDict(trimmedA)
|
||||
const dictB = charCountDict(trimmedB)
|
||||
|
||||
if (dictA.size !== dictB.size) return false
|
||||
|
||||
for (const [char, count] of dictA) {
|
||||
if (dictB.get(char) !== count) return false
|
||||
// Clear all existing child elements from the current root
|
||||
while (currentRoot.firstChild) {
|
||||
currentRoot.removeChild(currentRoot.firstChild);
|
||||
}
|
||||
return true
|
||||
|
||||
// Ensure the base cells exist
|
||||
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
||||
node => node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "0"
|
||||
);
|
||||
|
||||
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
||||
node => node.nodeName === "mxCell" &&
|
||||
(node as Element).getAttribute("id") === "1"
|
||||
);
|
||||
|
||||
// Copy all child nodes from the nodes root to the current root
|
||||
Array.from(nodesRoot.childNodes).forEach(node => {
|
||||
const importedNode = currentDoc.importNode(node, true);
|
||||
currentRoot.appendChild(importedNode);
|
||||
});
|
||||
|
||||
// Add default cells if they don't exist
|
||||
if (!hasCell0) {
|
||||
const cell0 = currentDoc.createElement("mxCell");
|
||||
cell0.setAttribute("id", "0");
|
||||
currentRoot.insertBefore(cell0, currentRoot.firstChild);
|
||||
}
|
||||
|
||||
if (!hasCell1) {
|
||||
const cell1 = currentDoc.createElement("mxCell");
|
||||
cell1.setAttribute("id", "1");
|
||||
cell1.setAttribute("parent", "0");
|
||||
|
||||
// Insert after cell0 if possible
|
||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]');
|
||||
if (cell0 && cell0.nextSibling) {
|
||||
currentRoot.insertBefore(cell1, cell0.nextSibling);
|
||||
} else {
|
||||
currentRoot.appendChild(cell1);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the modified DOM back to a string
|
||||
const serializer = new XMLSerializer();
|
||||
return serializer.serializeToString(currentDoc);
|
||||
} catch (error) {
|
||||
throw new Error(`Error replacing nodes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,213 +183,127 @@ function sameCharFrequency(a: string, b: string): boolean {
|
||||
* @returns The updated XML string with replacements applied
|
||||
*/
|
||||
export function replaceXMLParts(
|
||||
xmlContent: string,
|
||||
searchReplacePairs: Array<{ search: string; replace: string }>,
|
||||
xmlContent: string,
|
||||
searchReplacePairs: Array<{ search: string; replace: string }>
|
||||
): string {
|
||||
// Format the XML first to ensure consistent line breaks
|
||||
let result = formatXML(xmlContent)
|
||||
let lastProcessedIndex = 0
|
||||
// Format the XML first to ensure consistent line breaks
|
||||
let result = formatXML(xmlContent);
|
||||
let lastProcessedIndex = 0;
|
||||
|
||||
for (const { search, replace } of searchReplacePairs) {
|
||||
// Also format the search content for consistency
|
||||
const formattedSearch = formatXML(search)
|
||||
const searchLines = formattedSearch.split("\n")
|
||||
for (const { search, replace } of searchReplacePairs) {
|
||||
// Also format the search content for consistency
|
||||
const formattedSearch = formatXML(search);
|
||||
const searchLines = formattedSearch.split('\n');
|
||||
|
||||
// Split into lines for exact line matching
|
||||
const resultLines = result.split("\n")
|
||||
// Split into lines for exact line matching
|
||||
const resultLines = result.split('\n');
|
||||
|
||||
// Remove trailing empty line if exists (from the trailing \n in search content)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where lastProcessedIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (
|
||||
currentIndex < lastProcessedIndex &&
|
||||
startLineNum < resultLines.length
|
||||
) {
|
||||
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Try to find exact match starting from lastProcessedIndex
|
||||
let matchFound = false
|
||||
let matchStartLine = -1
|
||||
let matchEndLine = -1
|
||||
|
||||
// First try: exact match
|
||||
for (
|
||||
let i = startLineNum;
|
||||
i <= resultLines.length - searchLines.length;
|
||||
i++
|
||||
) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
if (resultLines[i + j] !== searchLines[j]) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
matchStartLine = i
|
||||
matchEndLine = i + searchLines.length
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: line-trimmed match (fallback)
|
||||
if (!matchFound) {
|
||||
for (
|
||||
let i = startLineNum;
|
||||
i <= resultLines.length - searchLines.length;
|
||||
i++
|
||||
) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = resultLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
matchStartLine = i
|
||||
matchEndLine = i + searchLines.length
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third try: substring match as last resort (for single-line XML)
|
||||
if (!matchFound) {
|
||||
// Try to find as a substring in the entire content
|
||||
const searchStr = search.trim()
|
||||
const resultStr = result
|
||||
const index = resultStr.indexOf(searchStr)
|
||||
|
||||
if (index !== -1) {
|
||||
// Found as substring - replace it
|
||||
result =
|
||||
resultStr.substring(0, index) +
|
||||
replace.trim() +
|
||||
resultStr.substring(index + searchStr.length)
|
||||
// Re-format after substring replacement
|
||||
result = formatXML(result)
|
||||
continue // Skip the line-based replacement below
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth try: character frequency match (attribute-order agnostic)
|
||||
// This handles cases where the model generates XML with different attribute order
|
||||
if (!matchFound) {
|
||||
for (
|
||||
let i = startLineNum;
|
||||
i <= resultLines.length - searchLines.length;
|
||||
i++
|
||||
) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
if (
|
||||
!sameCharFrequency(resultLines[i + j], searchLines[j])
|
||||
) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
matchStartLine = i
|
||||
matchEndLine = i + searchLines.length
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fifth try: Match by mxCell id attribute
|
||||
// Extract id from search pattern and find the element with that id
|
||||
if (!matchFound) {
|
||||
const idMatch = search.match(/id="([^"]+)"/)
|
||||
if (idMatch) {
|
||||
const searchId = idMatch[1]
|
||||
// Find lines that contain this id
|
||||
for (let i = startLineNum; i < resultLines.length; i++) {
|
||||
if (resultLines[i].includes(`id="${searchId}"`)) {
|
||||
// Found the element with matching id
|
||||
// Now find the extent of this element (it might span multiple lines)
|
||||
let endLine = i + 1
|
||||
const line = resultLines[i].trim()
|
||||
|
||||
// Check if it's a self-closing tag or has children
|
||||
if (!line.endsWith("/>")) {
|
||||
// Find the closing tag or the end of the mxCell block
|
||||
let depth = 1
|
||||
while (endLine < resultLines.length && depth > 0) {
|
||||
const currentLine = resultLines[endLine].trim()
|
||||
if (
|
||||
currentLine.startsWith("<") &&
|
||||
!currentLine.startsWith("</") &&
|
||||
!currentLine.endsWith("/>")
|
||||
) {
|
||||
depth++
|
||||
} else if (currentLine.startsWith("</")) {
|
||||
depth--
|
||||
}
|
||||
endLine++
|
||||
}
|
||||
}
|
||||
|
||||
matchStartLine = i
|
||||
matchEndLine = endLine
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
throw new Error(
|
||||
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replace the matched lines
|
||||
const replaceLines = replace.split("\n")
|
||||
|
||||
// Remove trailing empty line if exists
|
||||
if (replaceLines[replaceLines.length - 1] === "") {
|
||||
replaceLines.pop()
|
||||
}
|
||||
|
||||
// Perform the replacement
|
||||
const newResultLines = [
|
||||
...resultLines.slice(0, matchStartLine),
|
||||
...replaceLines,
|
||||
...resultLines.slice(matchEndLine),
|
||||
]
|
||||
|
||||
result = newResultLines.join("\n")
|
||||
|
||||
// Update lastProcessedIndex to the position after the replacement
|
||||
lastProcessedIndex = 0
|
||||
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
|
||||
lastProcessedIndex += newResultLines[i].length + 1
|
||||
}
|
||||
// Remove trailing empty line if exists (from the trailing \n in search content)
|
||||
if (searchLines[searchLines.length - 1] === '') {
|
||||
searchLines.pop();
|
||||
}
|
||||
|
||||
return result
|
||||
// Find the line number where lastProcessedIndex falls
|
||||
let startLineNum = 0;
|
||||
let currentIndex = 0;
|
||||
while (currentIndex < lastProcessedIndex && startLineNum < resultLines.length) {
|
||||
currentIndex += resultLines[startLineNum].length + 1; // +1 for \n
|
||||
startLineNum++;
|
||||
}
|
||||
|
||||
// Try to find exact match starting from lastProcessedIndex
|
||||
let matchFound = false;
|
||||
let matchStartLine = -1;
|
||||
let matchEndLine = -1;
|
||||
|
||||
// First try: exact match
|
||||
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
|
||||
let matches = true;
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
if (resultLines[i + j] !== searchLines[j]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
matchStartLine = i;
|
||||
matchEndLine = i + searchLines.length;
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: line-trimmed match (fallback)
|
||||
if (!matchFound) {
|
||||
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
|
||||
let matches = true;
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = resultLines[i + j].trim();
|
||||
const searchTrimmed = searchLines[j].trim();
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
matchStartLine = i;
|
||||
matchEndLine = i + searchLines.length;
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third try: substring match as last resort (for single-line XML)
|
||||
if (!matchFound) {
|
||||
// Try to find as a substring in the entire content
|
||||
const searchStr = search.trim();
|
||||
const resultStr = result;
|
||||
const index = resultStr.indexOf(searchStr);
|
||||
|
||||
if (index !== -1) {
|
||||
// Found as substring - replace it
|
||||
result = resultStr.substring(0, index) + replace.trim() + resultStr.substring(index + searchStr.length);
|
||||
// Re-format after substring replacement
|
||||
result = formatXML(result);
|
||||
continue; // Skip the line-based replacement below
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`);
|
||||
}
|
||||
|
||||
// Replace the matched lines
|
||||
const replaceLines = replace.split('\n');
|
||||
|
||||
// Remove trailing empty line if exists
|
||||
if (replaceLines[replaceLines.length - 1] === '') {
|
||||
replaceLines.pop();
|
||||
}
|
||||
|
||||
// Perform the replacement
|
||||
const newResultLines = [
|
||||
...resultLines.slice(0, matchStartLine),
|
||||
...replaceLines,
|
||||
...resultLines.slice(matchEndLine)
|
||||
];
|
||||
|
||||
result = newResultLines.join('\n');
|
||||
|
||||
// Update lastProcessedIndex to the position after the replacement
|
||||
lastProcessedIndex = 0;
|
||||
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
|
||||
lastProcessedIndex += newResultLines[i].length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,173 +312,167 @@ export function replaceXMLParts(
|
||||
* @returns null if valid, error message string if invalid
|
||||
*/
|
||||
export function validateMxCellStructure(xml: string): string | null {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xml, "text/xml")
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
|
||||
// Check for XML parsing errors (includes unescaped special characters)
|
||||
const parseError = doc.querySelector("parsererror")
|
||||
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.`
|
||||
// Check for XML parsing errors (includes unescaped special characters)
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
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.`;
|
||||
}
|
||||
|
||||
// Get all mxCell elements once for all validations
|
||||
const allCells = doc.querySelectorAll('mxCell');
|
||||
|
||||
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
|
||||
const cellIds = new Set<string>();
|
||||
const duplicateIds: string[] = [];
|
||||
const nestedCells: string[] = [];
|
||||
const orphanCells: string[] = [];
|
||||
const invalidParents: { id: string; parent: string }[] = [];
|
||||
const edgesToValidate: { id: string; source: string | null; target: string | null }[] = [];
|
||||
|
||||
allCells.forEach(cell => {
|
||||
const id = cell.getAttribute('id');
|
||||
const parent = cell.getAttribute('parent');
|
||||
const isEdge = cell.getAttribute('edge') === '1';
|
||||
|
||||
// Check for duplicate IDs
|
||||
if (id) {
|
||||
if (cellIds.has(id)) {
|
||||
duplicateIds.push(id);
|
||||
} else {
|
||||
cellIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all mxCell elements once for all validations
|
||||
const allCells = doc.querySelectorAll("mxCell")
|
||||
|
||||
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
|
||||
const cellIds = new Set<string>()
|
||||
const duplicateIds: string[] = []
|
||||
const nestedCells: string[] = []
|
||||
const orphanCells: string[] = []
|
||||
const invalidParents: { id: string; parent: string }[] = []
|
||||
const edgesToValidate: {
|
||||
id: string
|
||||
source: string | null
|
||||
target: string | null
|
||||
}[] = []
|
||||
|
||||
allCells.forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
const parent = cell.getAttribute("parent")
|
||||
const isEdge = cell.getAttribute("edge") === "1"
|
||||
|
||||
// Check for duplicate IDs
|
||||
if (id) {
|
||||
if (cellIds.has(id)) {
|
||||
duplicateIds.push(id)
|
||||
} else {
|
||||
cellIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for nested mxCell (parent element is also mxCell)
|
||||
if (cell.parentElement?.tagName === "mxCell") {
|
||||
nestedCells.push(id || "unknown")
|
||||
}
|
||||
|
||||
// Check parent attribute (skip root cell id="0")
|
||||
if (id !== "0") {
|
||||
if (!parent) {
|
||||
if (id) orphanCells.push(id)
|
||||
} else {
|
||||
// Store for later validation (after all IDs collected)
|
||||
invalidParents.push({ id: id || "unknown", parent })
|
||||
}
|
||||
}
|
||||
|
||||
// Collect edges for connection validation
|
||||
if (isEdge) {
|
||||
edgesToValidate.push({
|
||||
id: id || "unknown",
|
||||
source: cell.getAttribute("source"),
|
||||
target: cell.getAttribute("target"),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Return errors in priority order
|
||||
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.`
|
||||
// Check for nested mxCell (parent element is also mxCell)
|
||||
if (cell.parentElement?.tagName === 'mxCell') {
|
||||
nestedCells.push(id || 'unknown');
|
||||
}
|
||||
|
||||
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.`
|
||||
// Check parent attribute (skip root cell id="0")
|
||||
if (id !== '0') {
|
||||
if (!parent) {
|
||||
if (id) orphanCells.push(id);
|
||||
} else {
|
||||
// Store for later validation (after all IDs collected)
|
||||
invalidParents.push({ id: id || 'unknown', parent });
|
||||
}
|
||||
}
|
||||
|
||||
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.`
|
||||
// Collect edges for connection validation
|
||||
if (isEdge) {
|
||||
edgesToValidate.push({
|
||||
id: id || 'unknown',
|
||||
source: cell.getAttribute('source'),
|
||||
target: cell.getAttribute('target')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Validate parent references (now that all IDs are collected)
|
||||
const badParents = invalidParents.filter((p) => !cellIds.has(p.parent))
|
||||
if (badParents.length > 0) {
|
||||
const details = badParents
|
||||
.slice(0, 3)
|
||||
.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.`
|
||||
// Return errors in priority order
|
||||
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.`;
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
// Validate parent references (now that all IDs are collected)
|
||||
const badParents = invalidParents.filter(p => !cellIds.has(p.parent));
|
||||
if (badParents.length > 0) {
|
||||
const details = badParents.slice(0, 3).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
|
||||
const invalidConnections: string[] = [];
|
||||
edgesToValidate.forEach(edge => {
|
||||
if (edge.source && !cellIds.has(edge.source)) {
|
||||
invalidConnections.push(`${edge.id} (source: ${edge.source})`);
|
||||
}
|
||||
|
||||
// Validate edge connections
|
||||
const invalidConnections: string[] = []
|
||||
edgesToValidate.forEach((edge) => {
|
||||
if (edge.source && !cellIds.has(edge.source)) {
|
||||
invalidConnections.push(`${edge.id} (source: ${edge.source})`)
|
||||
}
|
||||
if (edge.target && !cellIds.has(edge.target)) {
|
||||
invalidConnections.push(`${edge.id} (target: ${edge.target})`)
|
||||
}
|
||||
})
|
||||
|
||||
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.`
|
||||
if (edge.target && !cellIds.has(edge.target)) {
|
||||
invalidConnections.push(`${edge.id} (target: ${edge.target})`);
|
||||
}
|
||||
});
|
||||
|
||||
return null
|
||||
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 null;
|
||||
}
|
||||
|
||||
export function extractDiagramXML(xml_svg_string: string): string {
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
const svgString = atob(xml_svg_string.slice(26))
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
|
||||
const svgElement = svgDoc.querySelector("svg")
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
const svgString = atob(xml_svg_string.slice(26));
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svgString, "image/svg+xml");
|
||||
const svgElement = svgDoc.querySelector('svg');
|
||||
|
||||
if (!svgElement) {
|
||||
throw new Error("No SVG element found in the input string.")
|
||||
}
|
||||
// 2. Extract the 'content' attribute
|
||||
const encodedContent = svgElement.getAttribute("content")
|
||||
|
||||
if (!encodedContent) {
|
||||
throw new Error("SVG element does not have a 'content' attribute.")
|
||||
}
|
||||
|
||||
// 3. Decode HTML entities (using a minimal function)
|
||||
function decodeHtmlEntities(str: string) {
|
||||
const textarea = document.createElement("textarea") // Use built-in element
|
||||
textarea.innerHTML = str
|
||||
return textarea.value
|
||||
}
|
||||
const xmlContent = decodeHtmlEntities(encodedContent)
|
||||
|
||||
// 4. Parse the XML content
|
||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
||||
const diagramElement = xmlDoc.querySelector("diagram")
|
||||
|
||||
if (!diagramElement) {
|
||||
throw new Error("No diagram element found")
|
||||
}
|
||||
// 5. Extract base64 encoded data
|
||||
const base64EncodedData = diagramElement.textContent
|
||||
|
||||
if (!base64EncodedData) {
|
||||
throw new Error("No encoded data found in the diagram element")
|
||||
}
|
||||
|
||||
// 6. Decode base64 data
|
||||
const binaryString = atob(base64EncodedData)
|
||||
|
||||
// 7. Convert binary string to Uint8Array
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
||||
const decompressedData = pako.inflate(bytes, { windowBits: -15 })
|
||||
|
||||
// 9. Convert the decompressed data to a string
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
const decodedString = decoder.decode(decompressedData)
|
||||
|
||||
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
||||
const urlDecodedString = decodeURIComponent(decodedString)
|
||||
|
||||
return urlDecodedString
|
||||
} catch (error) {
|
||||
console.error("Error extracting diagram XML:", error)
|
||||
throw error // Re-throw for caller handling
|
||||
if (!svgElement) {
|
||||
throw new Error("No SVG element found in the input string.");
|
||||
}
|
||||
// 2. Extract the 'content' attribute
|
||||
const encodedContent = svgElement.getAttribute('content');
|
||||
|
||||
if (!encodedContent) {
|
||||
throw new Error("SVG element does not have a 'content' attribute.");
|
||||
}
|
||||
|
||||
// 3. Decode HTML entities (using a minimal function)
|
||||
function decodeHtmlEntities(str: string) {
|
||||
const textarea = document.createElement('textarea'); // Use built-in element
|
||||
textarea.innerHTML = str;
|
||||
return textarea.value;
|
||||
}
|
||||
const xmlContent = decodeHtmlEntities(encodedContent);
|
||||
|
||||
// 4. Parse the XML content
|
||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml");
|
||||
const diagramElement = xmlDoc.querySelector('diagram');
|
||||
|
||||
if (!diagramElement) {
|
||||
throw new Error("No diagram element found");
|
||||
}
|
||||
// 5. Extract base64 encoded data
|
||||
const base64EncodedData = diagramElement.textContent;
|
||||
|
||||
if (!base64EncodedData) {
|
||||
throw new Error("No encoded data found in the diagram element");
|
||||
}
|
||||
|
||||
// 6. Decode base64 data
|
||||
const binaryString = atob(base64EncodedData);
|
||||
|
||||
// 7. Convert binary string to Uint8Array
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
||||
const decompressedData = pako.inflate(bytes, { windowBits: -15 });
|
||||
|
||||
// 9. Convert the decompressed data to a string
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const decodedString = decoder.decode(decompressedData);
|
||||
|
||||
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
||||
const urlDecodedString = decodeURIComponent(decodedString);
|
||||
|
||||
return urlDecodedString;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error extracting diagram XML:", error);
|
||||
throw error; // Re-throw for caller handling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NextConfig } from "next"
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
}
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig
|
||||
export default nextConfig;
|
||||
|
||||
3404
package-lock.json
generated
3404
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 6002",
|
||||
"build": "next build",
|
||||
"start": "next start --port 6001",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome check --write .",
|
||||
"check": "biome ci",
|
||||
"prepare": "husky"
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.62",
|
||||
@@ -20,16 +16,12 @@
|
||||
"@ai-sdk/google": "^2.0.0",
|
||||
"@ai-sdk/openai": "^2.0.19",
|
||||
"@ai-sdk/react": "^2.0.22",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
"@langfuse/otel": "^4.4.4",
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
@@ -40,7 +32,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"next": "^16.0.7",
|
||||
"next": "15.2.3",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -48,32 +40,19 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-drawio": "^1.0.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx,json,css}": [
|
||||
"biome check --write --no-errors-on-unmatched",
|
||||
"biome check --no-errors-on-unmatched"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.8",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
}
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,33 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user