Compare commits

...

36 Commits

Author SHA1 Message Date
dayuan.jiang
77f2569a3b chore: bump version to 0.3.0 2025-12-06 19:26:26 +09:00
Dayuan Jiang
cbb92bd636 fix: set maxDuration to 60 for Vercel hobby plan (#122) 2025-12-06 18:09:30 +09:00
Dayuan Jiang
8d898d8adc fix: revert maxDuration to static value (Next.js requirement) (#121) 2025-12-06 18:04:23 +09:00
Dayuan Jiang
1e0b1ed970 feat: make maxDuration configurable via MAX_DURATION env (#120) 2025-12-06 17:47:50 +09:00
Dayuan Jiang
1d03d10ba8 docs: add CONTRIBUTING.md (#119) 2025-12-06 17:39:47 +09:00
Dayuan Jiang
e893bd60f9 fix: resolve biome lint errors and memory leak in file preview (#118)
- Disable noisy biome rules (noExplicitAny, useExhaustiveDependencies, etc.)
- Fix memory leak in file-preview-list.tsx with useRef pattern
- Separate unmount cleanup into dedicated useEffect
- Add ToolPartLike interface for type safety in chat-message-display
- Add accessibility attributes (role, tabIndex, onKeyDown)
- Replace autoFocus with useEffect focus pattern
- Minor syntax improvements (optional chaining, key fixes)
2025-12-06 16:18:26 +09:00
Dayuan Jiang
9aaf9bf31f refactor: deduplicate system prompts with two-phase composition (#117) 2025-12-06 12:58:53 +09:00
Dayuan Jiang
150eb1ff63 chore: add Biome for formatting and linting (#116)
- Add Biome as formatter and linter (replaces Prettier)
- Configure Husky + lint-staged for pre-commit hooks
- Add VS Code settings for format on save
- Ignore components/ui/ (shadcn generated code)
- Remove semicolons, use 4-space indent
- Reformat all files to new style
2025-12-06 12:46:40 +09:00
Dayuan Jiang
215a101f54 fix: revert edit_diagram tool description to original (#115) 2025-12-06 12:41:01 +09:00
Dayuan Jiang
e00938d9d3 feat: enhance system prompt with app context and dynamic model name (#114)
- Add App Context section describing the left/right panel layout
- Add App Features section with icon locations (history, theme, upload, export, clear)
- Dynamically inject model name into system prompt via {{MODEL_NAME}} placeholder
- Expand edit_diagram tool description with usage guidelines
2025-12-06 12:37:37 +09:00
Dayuan Jiang
dd27d034e2 fix: force re-render when switching between mobile/desktop layout (#112) 2025-12-05 23:45:57 +09:00
Dayuan Jiang
9e781005af fix: make button hover state darker instead of lighter (#111) 2025-12-05 23:38:24 +09:00
Dayuan Jiang
fe1aa2747e fix: add viewport meta tag for mobile layout (#110) 2025-12-05 23:34:18 +09:00
Dayuan Jiang
7205896c8c feat: add mobile layout with chat panel at bottom (#109) 2025-12-05 23:25:59 +09:00
Dayuan Jiang
4e32a094b1 fix: restore status notice indicator removed in PR #77 (#107) 2025-12-05 23:22:29 +09:00
Dayuan Jiang
96a1111654 fix: ensure markdown text in user messages is visible (#108)
The prose plugin was overriding text colors for markdown elements
(bold, headings, etc.) in user message bubbles, causing text to
blend with the dark primary background.

Added conditional styling that forces all child elements in user
messages to use text-primary-foreground color with !important to
override prose defaults.
2025-12-05 23:16:59 +09:00
Dayuan Jiang
3f35c52527 feat: add draw.io theme toggle between minimal and sketch (#106)
- Add toggle button in chat input area to switch between min and sketch themes
- Show warning dialog before switching (clears messages and diagram)
- Persist theme selection in localStorage
- Default theme is minimal (hides shapes sidebar)
2025-12-05 23:10:48 +09:00
Dayuan Jiang
0af5229477 feat: add markdown rendering and resizable chat panel (#104)
* feat: add markdown rendering for chat messages

- Add react-markdown and @tailwindcss/typography for markdown support
- Use prose styling for assistant message formatting
- Fix Radix ScrollArea viewport horizontal overflow issue
- Add CSS fix for viewport width constraint

* feat: add resizable chat panel

- Replace fixed width layout with react-resizable-panels
- Chat panel can be resized by dragging the handle
- Panel is collapsible with min 15% and max 50% width
- Ctrl+B keyboard shortcut still works for toggle
2025-12-05 22:42:39 +09:00
Twelveeee
3fb349fb3e clear button cant clear error msg & feat: add setting dialog and add accesscode (#77)
* fix: clear button cant clear error msg

* new: add setting dialog and add accesscode

* fix: address review feedback - dark mode, types, formatting

* feat: only show Settings button when access code is required

* refactor: rename ACCESS_CODES to ACCESS_CODE_LIST

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-05 22:09:34 +09:00
Dayuan Jiang
ed29e32ba3 feat: restore Langfuse observability integration (#103)
- Add lib/langfuse.ts with client, trace input/output, telemetry config
- Add instrumentation.ts for OpenTelemetry setup with Langfuse span processor
- Add /api/log-save endpoint for logging diagram saves
- Add /api/log-feedback endpoint for thumbs up/down feedback
- Update chat route with sessionId tracking and telemetry
- Add feedback buttons (thumbs up/down) to chat messages
- Add sessionId tracking throughout the app
- Update env.example with Langfuse configuration
- Add @langfuse/client, @langfuse/otel, @langfuse/tracing, @opentelemetry/sdk-trace-node
2025-12-05 21:15:02 +09:00
Dayuan Jiang
4cd78dc561 chore: remove complex 503 error handling code (#102)
- Remove 15s streaming timeout detection (too slow, added complexity)
- Remove status indicator (issue resolved by switching model)
- Remove streamingError state and related refs
- Simplify onFinish callback (remove 503 detection logging)
- Remove errorHandler function (use default AI SDK errors)

The real fix was switching from global.* to us.* Bedrock model.
This removes ~134 lines of unnecessary complexity.
2025-12-05 20:18:19 +09:00
Dayuan Jiang
e0c5d966e3 feat: add image upload validation with 2MB limit and max 5 files (#101)
- Add 2MB file size limit with client and server-side validation
- Add max 5 files limit per upload
- Add sonner toast library for better error notifications
- Create ErrorToast component with keyboard accessibility
- Batch multiple validation errors into single toast
- Validate file size in all upload methods (input, paste, drag-drop)
- Add server-side validation in /api/chat endpoint
2025-12-05 19:30:50 +09:00
Dayuan Jiang
33471d5b3a docs: add AI provider configuration guide (#100)
- Add docs/ai-providers.md with detailed setup instructions for all providers
- Update README.md, README_CN.md, README_JA.md with provider guide links
- Add model capability requirements note
- Simplify provider list in READMEs

Closes #79
2025-12-05 18:53:34 +09:00
Dayuan Jiang
3ef9908df7 feat: add confirmation dialog to prevent accidental back navigation (#99)
Addresses conflict between right-click drag and browser back gesture in
Chromium-based browsers. Shows browser confirmation dialog when user
tries to navigate away, preventing accidental page exits.

Closes #80
2025-12-05 18:42:36 +09:00
Dayuan Jiang
57bfc9cef7 fix: update status indicator to show outage resolved (#98) 2025-12-05 18:07:25 +09:00
Dayuan Jiang
0543f71c43 fix: use console.log instead of console.error for XML validation during streaming (#96) 2025-12-05 16:59:14 +09:00
Dayuan Jiang
970b88612d fix: add service status indicator for ongoing issues (#95) 2025-12-05 16:46:17 +09:00
Dayuan Jiang
c805277a76 fix: enable UI retry when Bedrock returns early 503 error (#94)
- Add error prop to ChatInput to detect error state
- Update isDisabled logic to allow retry when there's an error
- Pass combined error (SDK error + streamingError) to ChatInput

When Bedrock returns 503 ServiceUnavailableException before streaming
starts, AI SDK's onError fires but status may not transition to "ready".
This fix ensures the input is re-enabled when an error occurs, allowing
users to retry their request.
2025-12-05 16:22:38 +09:00
Dayuan Jiang
95160f5a21 fix: handle Bedrock 503 streaming errors with timeout detection (#92)
- Add 15s streaming timeout to detect mid-stream stalls (e.g., Bedrock 503)
- Add stop() call to allow user retry after timeout
- Add streamingError state for timeout-detected errors
- Improve server-side error logging for empty usage detection
- Add user-friendly error messages for ServiceUnavailable and Throttling errors
2025-12-05 14:23:47 +09:00
broBinChen
b206e16c02 fix: clear files when clicking text-only examples (#82)
Fixed an issue where files from previous examples would persist when clicking on "Animated Diagram" or "Creative Drawing" examples that don't require image uploads.
2025-12-05 14:07:14 +09:00
broBinChen
563b18e8ff refactor: replace deprecated addToolResult with addToolOutput (#85)
Replaced the deprecated addToolResult API with the new addToolOutput API from ai to ensure compatibility with future versions.
2025-12-05 14:02:45 +09:00
dayuan.jiang
2366255e8f fix: use credential provider chain for bedrock IAM role support 2025-12-05 09:19:26 +09:00
dayuan.jiang
255308f829 fix: make bedrock credentials optional for IAM role support 2025-12-05 09:11:10 +09:00
dayuan.jiang
a9493c8877 fix: write env vars to .env.production for Amplify SSR runtime 2025-12-05 09:04:54 +09:00
dayuan.jiang
a0c3db100a fix: add favicon.ico to public folder for header logo 2025-12-05 08:56:34 +09:00
dayuan.jiang
ff6f130f8a refactor: remove Langfuse observability integration
- Delete lib/langfuse.ts, instrumentation.ts
- Remove API routes: log-save, log-feedback
- Remove feedback buttons (thumbs up/down) from chat
- Remove sessionId tracking throughout codebase
- Remove @langfuse/*, @opentelemetry dependencies
- Clean up env.example
2025-12-05 01:30:02 +09:00
52 changed files with 6576 additions and 2626 deletions

View File

@@ -1,6 +1,3 @@
{ {
"extends": [ "extends": ["next/core-web-vitals", "next/typescript"]
"next/core-web-vitals",
"next/typescript"
]
} }

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

23
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

35
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,35 @@
# Contributing
## Setup
```bash
git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git
cd next-ai-draw-io
npm install
cp env.example .env.local
npm run dev
```
## Code Style
We use [Biome](https://biomejs.dev/) for linting and formatting:
```bash
npm run format # Format code
npm run lint # Check lint errors
npm run check # Run all checks (CI)
```
Pre-commit hooks via Husky will run Biome automatically on staged files.
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
## Pull Requests
1. Create a feature branch
2. Make changes and ensure `npm run check` passes
3. Submit PR against `main` with a clear description
## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used.

View File

@@ -81,7 +81,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Multi-Provider Support ## Multi-Provider Support
- AWS Bedrock (default) - AWS Bedrock (default)
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`) - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,6 +89,12 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-4o, Gemini 2.0, and DeepSeek V3/R1.
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice. Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
## Getting Started ## Getting Started
@@ -143,8 +149,11 @@ Edit `.env.local` and configure your chosen provider:
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek) - Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
- Set `AI_MODEL` to the specific model you want to use - Set `AI_MODEL` to the specific model you want to use
- Add the required API keys for your provider - Add the required API keys for your provider
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples. > 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.
4. Run the development server: 4. Run the development server:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 多提供商支持 ## 多提供商支持
- AWS Bedrock默认 - AWS Bedrock默认
- OpenAI / OpenAI兼容API通过 `OPENAI_BASE_URL` - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。 注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。
## 快速开始 ## 快速开始
@@ -143,8 +149,11 @@ cp env.example .env.local
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek -`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
-`AI_MODEL` 设置为您要使用的特定模型 -`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥 - 添加您的提供商所需的API密钥
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例 > 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
4. 运行开发服务器: 4. 运行开发服务器:

View File

@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## マルチプロバイダーサポート ## マルチプロバイダーサポート
- AWS Bedrockデフォルト - AWS Bedrockデフォルト
- OpenAI / OpenAI互換API`OPENAI_BASE_URL`経由) - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Azure OpenAI - Azure OpenAI
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。 注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
## はじめに ## はじめに
@@ -143,8 +149,11 @@ cp env.example .env.local
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek - `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
- `AI_MODEL`を使用する特定のモデルに設定 - `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加 - プロバイダーに必要なAPIキーを追加
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください > 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
4. 開発サーバーを起動: 4. 開発サーバーを起動:

22
amplify.yml Normal file
View File

@@ -0,0 +1,22 @@
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/**/*

View File

@@ -1,13 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "关于 - Next AI Draw.io", title: "关于 - Next AI Draw.io",
description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。", description:
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
}; }
export default function AboutCN() { export default function AboutCN() {
return ( return (
@@ -16,14 +17,23 @@ export default function AboutCN() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link 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 Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link 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>
<Link href="/about/cn" className="text-blue-600 font-semibold"> <Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -45,22 +55,41 @@ export default function AboutCN() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">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"> <p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 - AI驱动的图表创建工具 -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <div className="flex justify-center gap-4 mt-4 text-sm">
<Link 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> <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> <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> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
Claude Opus 4.5 Claude Haiku 4.5 Claude Opus 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -69,80 +98,167 @@ export default function AboutCN() {
</p> </p>
{/* Features */} {/* 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"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>LLM驱动的图表创建</strong>draw.io图表</li> <li>
<li><strong></strong>AI自动复制和增强</li> <strong>LLM驱动的图表创建</strong>
<li><strong></strong>AI编辑前的图表版本</li> draw.io图表
<li><strong></strong>AI实时对话来完善您的图表</li> </li>
<li><strong>AWS架构图支持</strong>AWS架构图</li> <li>
<li><strong></strong></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> </ul>
{/* Examples */} {/* Examples */}
<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">
<p className="text-gray-700 mb-6"></p>
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Transformer连接器</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformer连接器
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong></strong>Transformer架构图 <strong></strong>
<strong></strong>Transformer架构图
</p> </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> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP架构图</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>GCP图标</strong>GCP架构图 <strong></strong> 使
<strong>GCP图标</strong>
GCP架构图
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>AWS图标</strong>AWS架构图 <strong></strong> 使
<strong>AWS图标</strong>
AWS架构图
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>Azure图标</strong>Azure架构图 <strong></strong> 使
<strong>Azure图标</strong>
Azure架构图
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong></strong>{" "}
</p> </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> </div>
</div> </div>
{/* How It Works */} {/* 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> <p className="text-gray-700 mb-4">使</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>Next.js</strong></li> <li>
<li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>用于流式AI响应和多提供商支持</li> <strong>Next.js</strong>
<li><strong>react-drawio</strong>:用于图表表示和操作</li> </li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
用于流式AI响应和多提供商支持
</li>
<li>
<strong>react-drawio</strong>:用于图表表示和操作
</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。 图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
</p> </p>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <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"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li> <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>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -151,12 +267,15 @@ export default function AboutCN() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code> AWS标志的draw.io图表上进行训练AWS架构图 <code>claude-sonnet-4-5</code>{" "}
AWS标志的draw.io图表上进行训练AWS架构图
</p> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900"></h2> <h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -167,14 +286,24 @@ export default function AboutCN() {
</div> </div>
<p className="text-gray-700"> <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>{" "} </a>{" "}
线 线
</p> </p>
<p className="text-gray-700 mt-2"> <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仓库 GitHub仓库
</a>{" "} </a>{" "}
issue或联系me[at]jiang.jp issue或联系me[at]jiang.jp
@@ -201,5 +330,5 @@ export default function AboutCN() {
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,13 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "概要 - Next AI Draw.io", title: "概要 - Next AI Draw.io",
description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。", description:
keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"], "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
}; keywords: [
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
}
export default function AboutJA() { export default function AboutJA() {
return ( return (
@@ -16,14 +24,23 @@ export default function AboutJA() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link 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 Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link 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>
<Link href="/about/ja" className="text-blue-600 font-semibold"> <Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -45,22 +62,43 @@ export default function AboutJA() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">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"> <p className="text-xl text-gray-600 font-medium">
AI搭載のダイアグラム作成ツール - AI搭載のダイアグラム作成ツール -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <div className="flex justify-center gap-4 mt-4 text-sm">
<Link 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> <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> <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> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
Claude Opus 4.5 Claude Haiku 4.5 Claude
Opus 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -69,80 +107,176 @@ export default function AboutJA() {
</p> </p>
{/* Features */} {/* 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"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>LLM搭載のダイアグラム作成</strong>draw.ioダイアグラムを作成</li> <li>
<li><strong></strong>AIが自動的に複製</li> <strong>LLM搭載のダイアグラム作成</strong>
<li><strong></strong>AI編集前のダイアグラムの以前のバージョンを表示</li> draw.ioダイアグラムを作成
<li><strong></strong>AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li> </li>
<li><strong>AWSアーキテクチャダイアグラムサポート</strong>AWSアーキテクチャダイアグラムの生成を専門的にサポート</li> <li>
<li><strong></strong></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> </ul>
{/* Examples */} {/* Examples */}
<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">
<p className="text-gray-700 mb-6"></p>
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Transformerコネクタ</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformerコネクタ
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong></strong>Transformerアーキテクチャ図を作成してください <strong></strong>{" "}
<strong></strong>
Transformerアーキテクチャ図を作成してください
</p> </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> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCPアーキテクチャ図</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>GCPアイコン</strong>使GCPアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>GCPアイコン</strong>
使GCPアーキテクチャ図を生成してください
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>AWSアイコン</strong>使AWSアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>AWSアイコン</strong>
使AWSアーキテクチャ図を生成してください
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>Azureアイコン</strong>使Azureアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>Azureアイコン</strong>
使Azureアーキテクチャ図を生成してください
</p> </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>
<div className="text-center"> <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"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong></strong>{" "}
</p> </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> </div>
</div> </div>
{/* How It Works */} {/* 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">
<p className="text-gray-700 mb-4">使</p>
</h2>
<p className="text-gray-700 mb-4">
使
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>Next.js</strong></li> <li>
<li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>ストリーミングAIレスポンスとマルチプロバイダーサポート</li> <strong>Next.js</strong>
<li><strong>react-drawio</strong>:ダイアグラムの表現と操作</li>
</li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>react-drawio</strong>
:ダイアグラムの表現と操作
</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。 ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
</p> </p>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <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"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li> <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>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -151,12 +285,15 @@ export default function AboutJA() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です <code>claude-sonnet-4-5</code>
AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です
</p> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900"></h2> <h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -167,14 +304,24 @@ export default function AboutJA() {
</div> </div>
<p className="text-gray-700"> <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>{" "} </a>{" "}
</p> </p>
<p className="text-gray-700 mt-2"> <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リポジトリ GitHubリポジトリ
</a>{" "} </a>{" "}
issueを開くかme[at]jiang.jp issueを開くかme[at]jiang.jp
@@ -196,10 +343,11 @@ export default function AboutJA() {
<footer className="bg-white border-t border-gray-200 mt-16"> <footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - AI搭載ダイアグラムジェネレーター Next AI Draw.io -
AI搭載ダイアグラムジェネレーター
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,13 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "About - Next AI Draw.io", 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.", description:
keywords: ["AI diagram", "draw.io", "AWS architecture", "GCP diagram", "Azure diagram", "LLM"], "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() { export default function About() {
return ( return (
@@ -16,14 +24,23 @@ export default function About() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link 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 Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors"> <Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Editor Editor
</Link> </Link>
<Link href="/about" className="text-blue-600 font-semibold"> <Link
href="/about"
className="text-blue-600 font-semibold"
>
About About
</Link> </Link>
<a <a
@@ -45,105 +62,236 @@ export default function About() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">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"> <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> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <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> <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> <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> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
This app is designed to run on Claude Opus 4.5 for 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> </p>
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
A Next.js web application that integrates AI capabilities with draw.io diagrams. A Next.js web application that integrates AI
Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization. capabilities with draw.io diagrams. Create, modify, and
enhance diagrams through natural language commands and
AI-assisted visualization.
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">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"> <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>
<li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li> <strong>LLM-Powered Diagram Creation</strong>:
<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> Leverage Large Language Models to create and
<li><strong>Interactive Chat Interface</strong>: Communicate with AI to refine your diagrams in real-time</li> manipulate draw.io diagrams directly through natural
<li><strong>AWS Architecture Diagram Support</strong>: Specialized support for generating AWS architecture diagrams</li> language commands
<li><strong>Animated Connectors</strong>: Create dynamic and animated connectors between diagram elements for better visualization</li> </li>
<li>
<strong>Image-Based Diagram Replication</strong>:
Upload existing diagrams or images and have the AI
replicate and enhance them automatically
</li>
<li>
<strong>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</li>
<li>
<strong>Interactive Chat Interface</strong>:
Communicate with AI to refine your diagrams in
real-time
</li>
<li>
<strong>AWS Architecture Diagram Support</strong>:
Specialized support for generating AWS architecture
diagrams
</li>
<li>
<strong>Animated Connectors</strong>: Create dynamic
and animated connectors between diagram elements for
better visualization
</li>
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p> Examples
</h2>
<p className="text-gray-700 mb-6">
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">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"> <p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer&apos;s architecture. <strong>Prompt:</strong> Give me an{" "}
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
</p> </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> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">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"> <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> </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>
<div className="text-center"> <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"> <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> </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>
<div className="text-center"> <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"> <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> </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>
<div className="text-center"> <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"> <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> </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> </div>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-4">The application uses the following technologies:</p> 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"> <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>
<li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li> <strong>Next.js</strong>: For the frontend framework
<li><strong>react-drawio</strong>: For diagram representation and manipulation</li> 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> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Diagrams are represented as XML that can be rendered in 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> </p>
{/* Multi-Provider Support */} {/* 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"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock (default)</li> <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>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -152,12 +300,17 @@ export default function About() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on 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> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">Support &amp; Contact</h2> <h2 className="text-2xl font-semibold text-gray-900">
Support &amp; Contact
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -168,14 +321,24 @@ export default function About() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
If you find this project useful, please consider{" "} If you find this project useful, please consider{" "}
<a 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 sponsoring
</a>{" "} </a>{" "}
to help host the live demo site! to help host the live demo site!
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
For support or inquiries, please open an issue on the{" "} For support or inquiries, please open an issue on the{" "}
<a 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 GitHub repository
</a>{" "} </a>{" "}
or contact: me[at]jiang.jp or contact: me[at]jiang.jp
@@ -197,10 +360,11 @@ export default function About() {
<footer className="bg-white border-t border-gray-200 mt-16"> <footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - Open Source AI-Powered Diagram Generator Next AI Draw.io - Open Source AI-Powered Diagram
Generator
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,198 +1,299 @@
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'; import {
import { getAIModel } from '@/lib/ai-providers'; convertToModelMessages,
import { findCachedResponse } from '@/lib/cached-responses'; createUIMessageStream,
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse'; createUIMessageStreamResponse,
import { getSystemPrompt } from '@/lib/system-prompts'; streamText,
import { z } from "zod"; } from "ai"
import { z } from "zod"
import { getAIModel } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
getTelemetryConfig,
setTraceInput,
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 300; 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 }
}
// Helper function to check if diagram is minimal/empty // Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean { function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, ''); const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"'); return !stripped.includes('id="2"')
} }
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`; const toolCallId = `cached-${Date.now()}`
const stream = createUIMessageStream({ const stream = createUIMessageStream({
execute: async ({ writer }) => { execute: async ({ writer }) => {
writer.write({ type: 'start' }); writer.write({ type: "start" })
writer.write({ type: 'tool-input-start', toolCallId, toolName: 'display_diagram' }); writer.write({
writer.write({ type: 'tool-input-delta', toolCallId, inputTextDelta: xml }); type: "tool-input-start",
writer.write({ type: 'tool-input-available', toolCallId, toolName: 'display_diagram', input: { xml } }); toolCallId,
writer.write({ type: 'finish' }); 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 // Inner handler function
async function handleChatRequest(req: Request): Promise<Response> { async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, sessionId } = await req.json(); // Check for access code
const accessCodes =
// Get user IP for Langfuse tracking process.env.ACCESS_CODE_LIST?.split(",")
const forwardedFor = req.headers.get('x-forwarded-for'); .map((code) => code.trim())
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; .filter(Boolean) || []
if (accessCodes.length > 0) {
// Validate sessionId for Langfuse (must be string, max 200 chars) const accessCodeHeader = req.headers.get("x-access-code")
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200 if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
? sessionId return Response.json(
: undefined; {
error: "Invalid or missing access code. Please configure it in Settings.",
// Extract user input text for Langfuse trace },
const currentMessage = messages[messages.length - 1]; { status: 401 },
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,
});
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1;
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0];
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text');
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file');
const cached = findCachedResponse(textPart?.text || '', !!filePart);
if (cached) {
console.log('[Cache] Returning cached response for:', textPart?.text);
return createCachedStreamResponse(cached.xml);
} }
}
// === CACHE CHECK END ===
// Get AI model from environment configuration const { messages, xml, sessionId } = await req.json()
const { model, providerOptions, headers, modelId } = getAIModel();
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // Get user IP for Langfuse tracking
const systemMessage = getSystemPrompt(modelId); const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const lastMessage = messages[messages.length - 1]; // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId =
sessionId && typeof sessionId === "string" && sessionId.length <= 200
? sessionId
: undefined
// Extract text from the last message parts // Extract user input text for Langfuse trace
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || ''; const currentMessage = messages[messages.length - 1]
const userInputText =
currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Extract file parts (images) from the last message // Update Langfuse trace with input, session, and user
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || []; setTraceInput({
input: userInputText,
sessionId: validSessionId,
userId: userId,
})
// User input only - XML is now in a separate cached system message // === FILE VALIDATION START ===
const formattedUserInput = `User input: const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 })
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) {
console.log(
"[Cache] Returning cached response for:",
textPart?.text,
)
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Get AI model from environment configuration
const { model, providerOptions, headers, modelId } = getAIModel()
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId)
const lastMessage = messages[messages.length - 1]
// Extract text from the last message parts
const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
// Extract file parts (images) from the last message
const fileParts =
lastMessage.parts?.filter((part: any) => part.type === "file") || []
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
"""md """md
${lastMessageText} ${lastMessageText}
"""`; """`
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages); const modelMessages = convertToModelMessages(messages)
// Filter out messages with empty content arrays (Bedrock API rejects these) // Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases // This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = modelMessages.filter((msg: any) => let enhancedMessages = modelMessages.filter(
msg.content && Array.isArray(msg.content) && msg.content.length > 0 (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 user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) { if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]; const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
if (lastModelMessage.role === 'user') { if (lastModelMessage.role === "user") {
// Build content array with user input text and file parts // Build content array with user input text and file parts
const contentParts: any[] = [ const contentParts: any[] = [
{ type: 'text', text: formattedUserInput } { type: "text", text: formattedUserInput },
]; ]
// Add image parts back // Add image parts back
for (const filePart of fileParts) { for (const filePart of fileParts) {
contentParts.push({ contentParts.push({
type: 'image', type: "image",
image: filePart.url, image: filePart.url,
mimeType: filePart.mediaType mimeType: filePart.mediaType,
}); })
} }
enhancedMessages = [ enhancedMessages = [
...enhancedMessages.slice(0, -1), ...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts } { ...lastModelMessage, content: contentParts },
]; ]
}
} }
}
// Add cache point to the last assistant message in conversation history // Add cache point to the last assistant message in conversation history
// This caches the entire conversation prefix for subsequent requests // This caches the entire conversation prefix for subsequent requests
// Strategy: system (cached) + history with last assistant (cached) + new user message // Strategy: system (cached) + history with last assistant (cached) + new user message
if (enhancedMessages.length >= 2) { if (enhancedMessages.length >= 2) {
// Find the last assistant message (should be second-to-last, before current user message) // Find the last assistant message (should be second-to-last, before current user message)
for (let i = enhancedMessages.length - 2; i >= 0; i--) { for (let i = enhancedMessages.length - 2; i >= 0; i--) {
if (enhancedMessages[i].role === 'assistant') { if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = { enhancedMessages[i] = {
...enhancedMessages[i], ...enhancedMessages[i],
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}; }
break; // Only cache the last assistant message break // Only cache the last assistant message
} }
}
} }
}
// System messages with multiple cache breakpoints for optimal caching: // System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes // - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn // - 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 // This allows: if only user message changes, both system caches are reused
// if XML changes, instruction cache is still reused // if XML changes, instruction cache is still reused
const systemMessages = [ const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change) // Cache breakpoint 1: Instructions (rarely change)
{ {
role: 'system' as const, role: "system" as const,
content: systemMessage, content: systemMessage,
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}, },
// Cache breakpoint 2: Current diagram XML context // Cache breakpoint 2: Current diagram XML context
{ {
role: 'system' as const, role: "system" as const,
content: `Current diagram XML:\n"""xml\n${xml || ''}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`, content: `Current diagram XML:\n"""xml\n${xml || ""}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}, },
]; ]
const allMessages = [...systemMessages, ...enhancedMessages]; const allMessages = [...systemMessages, ...enhancedMessages]
const result = streamText({ const result = streamText({
model, model,
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), ...(providerOptions && { providerOptions }),
...(headers && { headers }), ...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured) // Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && { ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }), experimental_telemetry: getTelemetryConfig({
}), sessionId: validSessionId,
onFinish: ({ text, usage, providerMetadata }) => { userId,
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) onFinish: ({ text, usage, providerMetadata }) => {
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens console.log(
setTraceOutput(text, { "[Cache] Full providerMetadata:",
promptTokens: usage?.inputTokens, JSON.stringify(providerMetadata, null, 2),
completionTokens: usage?.outputTokens, )
}); console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
}, // Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
tools: { // AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
// Client-side tool that will be executed on the client setTraceOutput(text, {
display_diagram: { promptTokens: usage?.inputTokens,
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags. completionTokens: usage?.outputTokens,
})
},
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated): VALIDATION RULES (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested 1. All mxCell elements must be DIRECT children of <root> - never nested
@@ -227,12 +328,14 @@ Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style. - For animated connectors, add "flowAnimation=1" to edge style.
`, `,
inputSchema: z.object({ inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io") 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. },
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. CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise: IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!) - COPY the exact mxCell line from the current XML (attribute order matters!)
@@ -240,57 +343,48 @@ IMPORTANT: Keep edits concise:
- Break large changes into multiple smaller edits - Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line) - Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element`, - First match only - be specific enough to target the right element`,
inputSchema: z.object({ inputSchema: z.object({
edits: z.array(z.object({ edits: z
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"), .array(
replace: z.string().describe("Replacement lines") z.object({
})).describe("Array of search/replace pairs to apply sequentially") search: z
}) .string()
}, .describe(
}, "EXACT lines copied from current XML (preserve attribute order!)",
temperature: 0, ),
}); replace: z
.string()
.describe("Replacement lines"),
}),
)
.describe(
"Array of search/replace pairs to apply sequentially",
),
}),
},
},
temperature: 0,
})
// Error handler function to provide detailed error messages return result.toUIMessageStreamResponse()
function errorHandler(error: unknown) {
if (error == null) {
return 'unknown error';
}
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;
}
return result.toUIMessageStreamResponse({
onError: errorHandler,
});
} }
// Wrap handler with error handling // Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> { async function safeHandler(req: Request): Promise<Response> {
try { try {
return await handleChatRequest(req); return await handleChatRequest(req)
} catch (error) { } catch (error) {
console.error('Error in chat route:', error); console.error("Error in chat route:", error)
return Response.json({ error: 'Internal server error' }, { status: 500 }); return Response.json(
} { error: "Internal server error" },
{ status: 500 },
)
}
} }
// Wrap with Langfuse observe (if configured) // Wrap with Langfuse observe (if configured)
const observedHandler = wrapWithObserve(safeHandler); const observedHandler = wrapWithObserve(safeHandler)
export async function POST(req: Request) { export async function POST(req: Request) {
return observedHandler(req); return observedHandler(req)
} }

12
app/api/config/route.ts Normal file
View File

@@ -0,0 +1,12 @@
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,
})
}

View File

@@ -1,103 +1,112 @@
import { getLangfuseClient } from '@/lib/langfuse'; import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'; import { z } from "zod"
import { z } from 'zod'; import { getLangfuseClient } from "@/lib/langfuse"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
messageId: z.string().min(1).max(200), messageId: z.string().min(1).max(200),
feedback: z.enum(['good', 'bad']), feedback: z.enum(["good", "bad"]),
sessionId: z.string().min(1).max(200).optional(), sessionId: z.string().min(1).max(200).optional(),
}); })
export async function POST(req: Request) { export async function POST(req: Request) {
const langfuse = getLangfuseClient(); const langfuse = getLangfuseClient()
if (!langfuse) { if (!langfuse) {
return Response.json({ success: true, logged: false }); 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 }); // Validate input
} catch (error) { let data
console.error('Langfuse feedback error:', error); try {
return Response.json({ success: false, error: 'Failed to log feedback' }, { status: 500 }); 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 },
)
}
} }

View File

@@ -1,65 +1,71 @@
import { getLangfuseClient } from '@/lib/langfuse'; import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'; import { z } from "zod"
import { z } from 'zod'; import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({ const saveSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
format: z.enum(['drawio', 'png', 'svg']), format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(), sessionId: z.string().min(1).max(200).optional(),
}); })
export async function POST(req: Request) { export async function POST(req: Request) {
const langfuse = getLangfuseClient(); const langfuse = getLangfuseClient()
if (!langfuse) { if (!langfuse) {
return Response.json({ success: true, logged: false }); 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 }); // Validate input
} catch (error) { let data
console.error('Langfuse save error:', error); try {
return Response.json({ success: false, error: 'Failed to log save' }, { status: 500 }); 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 },
)
}
} }

View File

@@ -1,248 +1,259 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
} }
:root { :root {
--radius: 0.75rem; --radius: 0.75rem;
/* Clean Light Modern Palette */ /* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240); --background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260); --foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260); --card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260); --popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */ /* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260); --primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0); --primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */ /* Warm gray secondary */
--secondary: oklch(0.96 0.005 260); --secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260); --secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */ /* Light muted tones */
--muted: oklch(0.965 0.005 260); --muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.50 0.02 260); --muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */ /* Soft lavender accent */
--accent: oklch(0.94 0.03 280); --accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270); --accent-foreground: oklch(0.35 0.08 270);
/* Coral destructive */ /* Coral destructive */
--destructive: oklch(0.60 0.20 25); --destructive: oklch(0.6 0.2 25);
/* Subtle borders */ /* Subtle borders */
--border: oklch(0.92 0.01 260); --border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260); --input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260); --ring: oklch(0.25 0.01 260);
/* Chart colors - harmonious palette */ /* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265); --chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170); --chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.70 0.18 45); --chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.60 0.20 330); --chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.50 0.15 200); --chart-5: oklch(0.5 0.15 200);
/* Sidebar */ /* Sidebar */
--sidebar: oklch(0.99 0.002 260); --sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260); --sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265); --sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270); --sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265); --sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260); --sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265); --sidebar-ring: oklch(0.55 0.18 265);
} }
.dark { .dark {
--background: oklch(0.15 0.015 260); --background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260); --foreground: oklch(0.95 0.01 260);
--card: oklch(0.20 0.015 260); --card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260); --card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.20 0.015 260); --popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260); --popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.70 0.16 265); --primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260); --primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260); --secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.90 0.01 260); --secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260); --muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260); --muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.30 0.04 280); --accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.90 0.03 270); --accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.65 0.22 25); --destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260); --border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260); --input: oklch(0.25 0.015 260);
--ring: oklch(0.70 0.16 265); --ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.70 0.16 265); --chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.70 0.13 170); --chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45); --chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.70 0.18 330); --chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.60 0.13 200); --chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260); --sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260); --sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.70 0.16 265); --sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260); --sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270); --sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.90 0.02 265); --sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260); --sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.70 0.16 265); --sidebar-ring: oklch(0.7 0.16 265);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground font-sans; @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;
} }
/* Custom scrollbar */ /* Custom scrollbar */
@layer utilities { @layer utilities {
.scrollbar-thin { .scrollbar-thin {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 260) transparent; scrollbar-color: oklch(0.85 0.01 260) transparent;
} }
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
width: 6px; width: 6px;
} }
.scrollbar-thin::-webkit-scrollbar-track { .scrollbar-thin::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scrollbar-thin::-webkit-scrollbar-thumb { .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.85 0.01 260); background-color: oklch(0.85 0.01 260);
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260); background-color: oklch(0.75 0.01 260);
} }
} }
/* Smooth page transitions */ /* Smooth page transitions */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes slideInRight { @keyframes slideInRight {
from { from {
opacity: 0; opacity: 0;
transform: translateX(16px); transform: translateX(16px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
} }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.3s ease-out forwards; animation: fadeIn 0.3s ease-out forwards;
} }
.animate-slide-in-right { .animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards; animation: slideInRight 0.3s ease-out forwards;
} }
/* Message bubble animations */ /* Message bubble animations */
@keyframes messageIn { @keyframes messageIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(12px) scale(0.98); transform: translateY(12px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
.animate-message-in { .animate-message-in {
animation: messageIn 0.25s ease-out forwards; animation: messageIn 0.25s ease-out forwards;
} }
/* Subtle floating shadow for cards */ /* Subtle floating shadow for cards */
.shadow-soft { .shadow-soft {
box-shadow: box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04), 0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06), 0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04); 0 8px 24px oklch(0.23 0.02 260 / 0.04);
} }
.shadow-soft-lg { .shadow-soft-lg {
box-shadow: box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04), 0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08), 0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06); 0 16px 40px oklch(0.23 0.02 260 / 0.06);
} }
/* Gradient text utility */ /* Gradient text utility */
.text-gradient-primary { .text-gradient-primary {
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290)); background: linear-gradient(
-webkit-background-clip: text; 135deg,
-webkit-text-fill-color: transparent; oklch(0.55 0.18 265),
background-clip: text; oklch(0.6 0.2 290)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }

View File

@@ -1,34 +1,53 @@
import type { Metadata } from "next"; import { GoogleAnalytics } from "@next/third-parties/google"
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/react"
import { Analytics } from "@vercel/analytics/react"; import type { Metadata, Viewport } from "next"
import { GoogleAnalytics } from "@next/third-parties/google"; import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { DiagramProvider } from "@/contexts/diagram-context"; import { DiagramProvider } from "@/contexts/diagram-context"
import "./globals.css"; import "./globals.css"
const plusJakarta = Plus_Jakarta_Sans({ const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans", variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"], weight: ["400", "500", "600", "700"],
}); })
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono", variable: "--font-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500"], weight: ["400", "500"],
}); })
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Next AI Draw.io - AI-Powered Diagram Generator", title: "Next AI Draw.io - AI-Powered Diagram Generator",
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", description:
keywords: ["AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker"], "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" }], authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io", creator: "Next AI Draw.io",
publisher: "Next AI Draw.io", publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"), metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: { openGraph: {
title: "Next AI Draw.io - AI Diagram Generator", title: "Next AI Draw.io - AI Diagram Generator",
description: "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", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
@@ -45,7 +64,8 @@ export const metadata: Metadata = {
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Next AI Draw.io - AI Diagram Generator", title: "Next AI Draw.io - AI Diagram Generator",
description: "Create professional diagrams with AI assistance. Free, no login required.", description:
"Create professional diagrams with AI assistance. Free, no login required.",
images: ["/architecture.png"], images: ["/architecture.png"],
}, },
robots: { robots: {
@@ -62,27 +82,28 @@ export const metadata: Metadata = {
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
}, },
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'SoftwareApplication', "@type": "SoftwareApplication",
name: 'Next AI Draw.io', name: "Next AI Draw.io",
applicationCategory: 'DesignApplication', applicationCategory: "DesignApplication",
operatingSystem: 'Web Browser', operatingSystem: "Web Browser",
description: '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.', description:
url: 'https://next-ai-drawio.jiang.jp', "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: { offers: {
'@type': 'Offer', "@type": "Offer",
price: '0', price: "0",
priceCurrency: 'USD', priceCurrency: "USD",
}, },
}; }
return ( return (
<html lang="en"> <html lang="en">
@@ -96,12 +117,11 @@ export default function RootLayout({
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`} className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
> >
<DiagramProvider>{children}</DiagramProvider> <DiagramProvider>{children}</DiagramProvider>
<Analytics /> <Analytics />
</body> </body>
{process.env.NEXT_PUBLIC_GA_ID && ( {process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} /> <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)} )}
</html> </html>
); )
} }

View File

@@ -1,85 +1,136 @@
"use client"; "use client"
import React, { useState, useEffect } from "react"; import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"; import { DrawIoEmbed } from "react-drawio"
import ChatPanel from "@/components/chat-panel"; import type { ImperativePanelHandle } from "react-resizable-panels"
import { useDiagram } from "@/contexts/diagram-context"; import ChatPanel from "@/components/chat-panel"
import { Monitor } from "lucide-react"; import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram(); const { drawioRef, handleDiagramExport } = useDiagram()
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true); const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved
}
return "min"
})
const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768)
}; }
checkMobile(); checkMobile()
window.addEventListener("resize", checkMobile); window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile)
}, []); }, [])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
if (panel.isCollapsed()) {
panel.expand()
setIsChatVisible(true)
} else {
panel.collapse()
setIsChatVisible(false)
}
}
}
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'b') { if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault(); event.preventDefault()
setIsChatVisible((prev) => !prev); toggleChatPanel()
} }
}; }
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown)
}, []); }, [])
// Show confirmation dialog when user tries to leave the page
// 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)
}, [])
return ( return (
<div className="flex h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
{/* Mobile warning overlay */} <ResizablePanelGroup
{isMobile && ( key={isMobile ? "mobile" : "desktop"}
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background"> direction={isMobile ? "vertical" : "horizontal"}
<div className="text-center p-8 max-w-sm mx-auto animate-fade-in"> className="h-full"
<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" /> {/* 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> </div>
<h1 className="text-xl font-semibold text-foreground mb-3">
Desktop Required
</h1>
<p className="text-sm text-muted-foreground leading-relaxed">
This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
</p>
</div> </div>
</div> </ResizablePanel>
)}
{/* Draw.io Canvas */} <ResizableHandle withHandle />
<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 */} {/* Chat Panel */}
<div <ResizablePanel
className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`} ref={chatPanelRef}
> defaultSize={isMobile ? 50 : 33}
<div className="h-full py-2 pr-2"> minSize={isMobile ? 20 : 15}
<ChatPanel maxSize={isMobile ? 80 : 50}
isVisible={isChatVisible} collapsible={!isMobile}
onToggleVisibility={() => setIsChatVisible(!isChatVisible)} collapsedSize={isMobile ? 0 : 3}
/> onCollapse={() => setIsChatVisible(false)}
</div> onExpand={() => setIsChatVisible(true)}
</div> >
<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>
</div> </div>
); )
} }

View File

@@ -1,12 +1,12 @@
import { MetadataRoute } from 'next' import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { rules: {
userAgent: '*', userAgent: "*",
allow: '/', allow: "/",
disallow: '/api/', disallow: "/api/",
}, },
sitemap: 'https://next-ai-drawio.jiang.jp/sitemap.xml', sitemap: "https://next-ai-drawio.jiang.jp/sitemap.xml",
} }
} }

View File

@@ -1,17 +1,17 @@
import { MetadataRoute } from 'next' import type { MetadataRoute } from "next"
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: 'https://next-ai-drawio.jiang.jp', url: "https://next-ai-drawio.jiang.jp",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
{ {
url: 'https://next-ai-drawio.jiang.jp/about', url: "https://next-ai-drawio.jiang.jp/about",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
}, },
] ]

83
biome.json Normal file
View File

@@ -0,0 +1,83 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noImportantStyles": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off"
},
"a11y": {
"useButtonType": "off",
"noAutofocus": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off",
"noNoninteractiveTabindex": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"style": {
"useNodejsImportProtocol": "off",
"useTemplate": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["components/ui/**"],
"formatter": {
"enabled": false
},
"linter": {
"enabled": false
},
"assist": {
"enabled": false
}
}
]
}

View File

@@ -1,21 +1,21 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "new-york",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "app/globals.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide" "iconLibrary": "lucide"
} }

View File

@@ -1,19 +1,19 @@
import React from "react"; import type { VariantProps } from "class-variance-authority"
import { Button, buttonVariants } from "@/components/ui/button"; import type React from "react"
import { Button, type buttonVariants } from "@/components/ui/button"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip"
import { type VariantProps } from "class-variance-authority";
interface ButtonWithTooltipProps interface ButtonWithTooltipProps
extends React.ComponentProps<"button">, extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
tooltipContent: string; tooltipContent: string
children: React.ReactNode; children: React.ReactNode
asChild?: boolean; asChild?: boolean
} }
export function ButtonWithTooltip({ export function ButtonWithTooltip({
@@ -27,8 +27,10 @@ export function ButtonWithTooltip({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button {...buttonProps}>{children}</Button> <Button {...buttonProps}>{children}</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent> <TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); )
} }

View File

@@ -1,12 +1,12 @@
"use client"; "use client"
import { Zap, Cloud, GitBranch, Palette } from "lucide-react"; import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps { interface ExampleCardProps {
icon: React.ReactNode; icon: React.ReactNode
title: string; title: string
description: string; description: string
onClick: () => void; onClick: () => void
} }
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) { function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
</div> </div>
</div> </div>
</button> </button>
); )
} }
export default function ExamplePanel({ export default function ExamplePanel({
setInput, setInput,
setFiles, setFiles,
}: { }: {
setInput: (input: string) => void; setInput: (input: string) => void
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void
}) { }) {
const handleReplicateFlowchart = async () => { const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart."); setInput("Replicate this flowchart.")
try { try {
const response = await fetch("/example.png"); const response = await fetch("/example.png")
const blob = await response.blob(); const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" }); const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file]); setFiles([file])
} catch (error) { } catch (error) {
console.error("Error loading example image:", error); console.error("Error loading example image:", error)
} }
}; }
const handleReplicateArchitecture = async () => { const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style"); setInput("Replicate this in aws style")
try { try {
const response = await fetch("/architecture.png"); const response = await fetch("/architecture.png")
const blob = await response.blob(); const blob = await response.blob()
const file = new File([blob], "architecture.png", { const file = new File([blob], "architecture.png", {
type: "image/png", type: "image/png",
}); })
setFiles([file]); setFiles([file])
} catch (error) { } catch (error) {
console.error("Error loading architecture image:", error); console.error("Error loading architecture image:", error)
} }
}; }
return ( return (
<div className="py-6 px-2 animate-fade-in"> <div className="py-6 px-2 animate-fade-in">
@@ -75,7 +75,8 @@ export default function ExamplePanel({
Create diagrams with AI Create diagrams with AI
</h2> </h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto"> <p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to replicate Describe what you want to create or upload an image to
replicate
</p> </p>
</div> </div>
@@ -90,7 +91,12 @@ export default function ExamplePanel({
icon={<Zap className="w-4 h-4 text-primary" />} icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram" title="Animated Diagram"
description="Draw a transformer architecture with animated connectors" description="Draw a transformer architecture with animated connectors"
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")} onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/> />
<ExampleCard <ExampleCard
@@ -111,7 +117,10 @@ export default function ExamplePanel({
icon={<Palette className="w-4 h-4 text-primary" />} icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing" title="Creative Drawing"
description="Draw something fun and creative" description="Draw something fun and creative"
onClick={() => setInput("Draw a cat for me")} onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/> />
</div> </div>
@@ -120,5 +129,5 @@ export default function ExamplePanel({
</p> </p>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,35 +1,130 @@
"use client"; "use client"
import React, { useCallback, useRef, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ResetWarningModal } from "@/components/reset-warning-modal";
import { SaveDialog } from "@/components/save-dialog";
import { import {
Download,
History,
Image as ImageIcon,
LayoutGrid,
Loader2, Loader2,
PenTool,
Send, Send,
Trash2, Trash2,
Image as ImageIcon, } from "lucide-react"
History, import type React from "react"
Download, import { useCallback, useEffect, useRef, useState } from "react"
Paperclip, import { toast } from "sonner"
} from "lucide-react"; import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "./file-preview-list"; import { HistoryDialog } from "@/components/history-dialog"
import { useDiagram } from "@/contexts/diagram-context"; import { ResetWarningModal } from "@/components/reset-warning-modal"
import { HistoryDialog } from "@/components/history-dialog"; 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>,
)
}
}
interface ChatInputProps { interface ChatInputProps {
input: string; input: string
status: "submitted" | "streaming" | "ready" | "error"; status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void; onClearChat: () => void
files?: File[]; files?: File[]
onFileChange?: (files: File[]) => void; onFileChange?: (files: File[]) => void
showHistory?: boolean; showHistory?: boolean
onToggleHistory?: (show: boolean) => void; onToggleHistory?: (show: boolean) => void
sessionId?: string; sessionId?: string
error?: Error | null
drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void
} }
export function ChatInput({ export function ChatInput({
@@ -43,124 +138,143 @@ export function ChatInput({
showHistory = false, showHistory = false,
onToggleHistory = () => {}, onToggleHistory = () => {},
sessionId, sessionId,
error = null,
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram(); const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false)
const isDisabled = status === "streaming" || status === "submitted"; // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
useEffect(() => { (status === "streaming" || status === "submitted") && !error
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
const adjustTextareaHeight = useCallback(() => { const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current
if (textarea) { if (textarea) {
textarea.style.height = "auto"; textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
} }
}, []); }, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => { useEffect(() => {
adjustTextareaHeight(); adjustTextareaHeight()
}, [input, adjustTextareaHeight]); }, [input, adjustTextareaHeight])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault(); e.preventDefault()
const form = e.currentTarget.closest("form"); const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
form.requestSubmit(); form.requestSubmit()
} }
} }
}; }
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return; if (isDisabled) return
const items = e.clipboardData.items; const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/") item.type.startsWith("image/"),
); )
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = await Promise.all( const imageFiles = (
imageItems.map(async (item) => { await Promise.all(
const file = item.getAsFile(); imageItems.map(async (item, index) => {
if (!file) return null; const file = item.getAsFile()
return new File( if (!file) return null
[file], return new File(
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`, [file],
{ `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
type: file.type, { type: file.type },
} )
); }),
}) )
); ).filter((f): f is File => f !== null)
const validFiles = imageFiles.filter( const { validFiles, errors } = validateFiles(
(file): file is File => file !== null imageFiles,
); files.length,
)
showValidationErrors(errors)
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]); onFileChange([...files, ...validFiles])
} }
} }
}; }
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []); const newFiles = Array.from(e.target.files || [])
onFileChange([...files, ...newFiles]); 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 handleRemoveFile = (fileToRemove: File) => { const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove)); onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = ""
} }
}; }
const triggerFileInput = () => { const triggerFileInput = () => {
fileInputRef.current?.click(); fileInputRef.current?.click()
}; }
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(true); setIsDragging(true)
}; }
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => { const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(false); setIsDragging(false)
}; }
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => { const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(false); setIsDragging(false)
if (isDisabled) return; if (isDisabled) return
const droppedFiles = e.dataTransfer.files;
const droppedFiles = e.dataTransfer.files
const imageFiles = Array.from(droppedFiles).filter((file) => const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/") file.type.startsWith("image/"),
); )
if (imageFiles.length > 0) { const { validFiles, errors } = validateFiles(imageFiles, files.length)
onFileChange([...files, ...imageFiles]); showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
} }
}; }
const handleClear = () => { const handleClear = () => {
onClearChat(); onClearChat()
setShowClearDialog(false); setShowClearDialog(false)
}; }
return ( return (
<form <form
@@ -177,7 +291,10 @@ export function ChatInput({
{/* File previews */} {/* File previews */}
{files.length > 0 && ( {files.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} /> <FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
/>
</div> </div>
)} )}
@@ -186,7 +303,7 @@ export function ChatInput({
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={onChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder="Describe your diagram or paste an image..." placeholder="Describe your diagram or paste an image..."
@@ -220,6 +337,60 @@ export function ChatInput({
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={onToggleHistory} onToggleHistory={onToggleHistory}
/> />
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowThemeWarning(true)}
tooltipContent={
drawioUi === "min"
? "Switch to Sketch theme"
: "Switch to Minimal theme"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{drawioUi === "min" ? (
<PenTool className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</ButtonWithTooltip>
<Dialog
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription>
Switching themes will reload the diagram
editor and clear any unsaved changes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setShowThemeWarning(false)
}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat()
onToggleDrawioUi()
setShowThemeWarning(false)
}}
>
Switch Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
{/* Right actions */} {/* Right actions */}
@@ -251,8 +422,12 @@ export function ChatInput({
<SaveDialog <SaveDialog
open={showSaveDialog} open={showSaveDialog}
onOpenChange={setShowSaveDialog} onOpenChange={setShowSaveDialog}
onSave={(filename, format) => saveDiagramToFile(filename, format, sessionId)} onSave={(filename, format) =>
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`} saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/> />
<ButtonWithTooltip <ButtonWithTooltip
@@ -284,7 +459,9 @@ export function ChatInput({
disabled={isDisabled || !input.trim()} disabled={isDisabled || !input.trim()}
size="sm" size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm" className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={isDisabled ? "Sending..." : "Send message"} aria-label={
isDisabled ? "Sending..." : "Send message"
}
> >
{isDisabled ? ( {isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -298,7 +475,6 @@ export function ChatInput({
</div> </div>
</div> </div>
</div> </div>
</form> </form>
); )
} }

View File

@@ -1,24 +1,55 @@
"use client"; "use client"
import { useRef, useEffect, useState, useCallback } from "react"; import type { UIMessage } from "ai"
import Image from "next/image";
import { ScrollArea } from "@/components/ui/scroll-area"; import {
import ExamplePanel from "./chat-example-panel"; Check,
import { UIMessage } from "ai"; ChevronDown,
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils"; ChevronUp,
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react"; Copy,
import { CodeBlock } from "./code-block"; Cpu,
Minus,
Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
convertToLegalXml,
replaceNodes,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface EditPair { interface EditPair {
search: string; search: string
replace: string; replace: string
}
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
} }
function EditDiffDisplay({ edits }: { edits: EditPair[] }) { function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{edits.map((edit, index) => ( {edits.map((edit, index) => (
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50"> <div
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Change {index + 1} Change {index + 1}
@@ -29,7 +60,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Minus className="w-3 h-3 text-red-500" /> <Minus className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span> <span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div> </div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search} {edit.search}
@@ -39,7 +72,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" /> <Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span> <span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div> </div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace} {edit.replace}
@@ -49,75 +84,78 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
</div> </div>
))} ))}
</div> </div>
); )
} }
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => { const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return ""; if (!message.parts) return ""
return message.parts return message.parts
.filter((part: any) => part.type === "text") .filter((part) => part.type === "text")
.map((part: any) => part.text) .map((part) => (part as { text: string }).text)
.join("\n"); .join("\n")
}; }
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[]; messages: UIMessage[]
error?: Error | null; setInput: (input: string) => void
setInput: (input: string) => void; setFiles: (files: File[]) => void
setFiles: (files: File[]) => void; sessionId?: string
sessionId?: string; onRegenerate?: (messageIndex: number) => void
onRegenerate?: (messageIndex: number) => void; onEditMessage?: (messageIndex: number, newText: string) => void
onEditMessage?: (messageIndex: number, newText: string) => void;
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
messages, messages,
error,
setInput, setInput,
setFiles, setFiles,
sessionId, sessionId,
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram(); const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>(""); const previousXML = useRef<string>("")
const processedToolCalls = useRef<Set<string>>(new Set()); const processedToolCalls = useRef<Set<string>>(new Set())
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{} {},
); )
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null); const [copyFailedMessageId, setCopyFailedMessageId] = useState<
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({}); string | null
const [editingMessageId, setEditingMessageId] = useState<string | null>(null); >(null)
const [editText, setEditText] = useState<string>(""); const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
const [editingMessageId, setEditingMessageId] = useState<string | null>(
null,
)
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => { const copyMessageToClipboard = async (messageId: string, text: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId); setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000); setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) { } catch (err) {
console.error("Failed to copy message:", err); console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId); setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000); setTimeout(() => setCopyFailedMessageId(null), 2000)
} }
}; }
const submitFeedback = async (messageId: string, value: "good" | "bad") => { const submitFeedback = async (messageId: string, value: "good" | "bad") => {
// Toggle off if already selected // Toggle off if already selected
if (feedback[messageId] === value) { if (feedback[messageId] === value) {
setFeedback((prev) => { setFeedback((prev) => {
const next = { ...prev }; const next = { ...prev }
delete next[messageId]; delete next[messageId]
return next; return next
}); })
return; return
} }
setFeedback((prev) => ({ ...prev, [messageId]: value })); setFeedback((prev) => ({ ...prev, [messageId]: value }))
try { try {
await fetch("/api/log-feedback", { await fetch("/api/log-feedback", {
@@ -128,97 +166,108 @@ export function ChatMessageDisplay({
feedback: value, feedback: value,
sessionId, sessionId,
}), }),
}); })
} catch (error) { } catch (error) {
console.warn("Failed to log feedback:", error); console.warn("Failed to log feedback:", error)
} }
}; }
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string) => { (xml: string) => {
const currentXml = xml || ""; const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml); const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml); const replacedXML = replaceNodes(chartXML, convertedXml)
const validationError = validateMxCellStructure(replacedXML); const validationError = validateMxCellStructure(replacedXML)
if (!validationError) { if (!validationError) {
previousXML.current = convertedXml; previousXML.current = convertedXml
onDisplayChart(replacedXML); onDisplayChart(replacedXML)
} else { } else {
console.error("[ChatMessageDisplay] XML validation failed:", validationError); console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
} }
} }
}, },
[chartXML, onDisplayChart] [chartXML, onDisplayChart],
); )
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]); }, [messages])
useEffect(() => {
if (editingMessageId && editTextareaRef.current) {
editTextareaRef.current.focus()
}
}, [editingMessageId])
useEffect(() => { useEffect(() => {
messages.forEach((message) => { messages.forEach((message) => {
if (message.parts) { if (message.parts) {
message.parts.forEach((part: any) => { message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part; const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
...prev, ...prev,
[toolCallId]: false, [toolCallId]: false,
})); }))
} }
if ( if (
part.type === "tool-display_diagram" && part.type === "tool-display_diagram" &&
part.input?.xml input?.xml
) { ) {
const xml = input.xml as string
if ( if (
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
handleDisplayChart(part.input.xml); handleDisplayChart(xml)
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
handleDisplayChart(part.input.xml); handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId); processedToolCalls.current.add(toolCallId)
} }
} }
} }
}); })
} }
}); })
}, [messages, handleDisplayChart]); }, [messages, handleDisplayChart])
const renderToolPart = (part: any) => { const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId; const callId = part.toolCallId
const { state, input, output } = part; const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true; const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", ""); const toolName = part.type?.replace("tool-", "")
const toggleExpanded = () => { const toggleExpanded = () => {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
...prev, ...prev,
[callId]: !isExpanded, [callId]: !isExpanded,
})); }))
}; }
const getToolDisplayName = (name: string) => { const getToolDisplayName = (name: string) => {
switch (name) { switch (name) {
case "display_diagram": case "display_diagram":
return "Generate Diagram"; return "Generate Diagram"
case "edit_diagram": case "edit_diagram":
return "Edit Diagram"; return "Edit Diagram"
default: default:
return name; return name
} }
}; }
return ( return (
<div <div
@@ -250,6 +299,7 @@ export function ChatMessageDisplay({
)} )}
{input && Object.keys(input).length > 0 && ( {input && Object.keys(input).length > 0 && (
<button <button
type="button"
onClick={toggleExpanded} onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors" className="p-1 rounded hover:bg-muted transition-colors"
> >
@@ -266,10 +316,16 @@ export function ChatMessageDisplay({
<div className="px-4 py-3 border-t border-border/40 bg-muted/20"> <div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" /> <CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? ( ) : typeof input === "object" &&
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} /> <EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && Object.keys(input).length > 0 ? ( ) : typeof input === "object" &&
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" /> Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null} ) : null}
</div> </div>
)} )}
@@ -279,102 +335,166 @@ export function ChatMessageDisplay({
</div> </div>
)} )}
</div> </div>
); )
}; }
return ( return (
<ScrollArea className="h-full px-4 scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 ? ( {messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} /> <ExamplePanel setInput={setInput} setFiles={setFiles} />
) : ( ) : (
<div className="py-4 space-y-4"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : ""; const userMessageText =
const isLastAssistantMessage = message.role === "assistant" && ( message.role === "user"
messageIndex === messages.length - 1 || ? getMessageTextContent(message)
messages.slice(messageIndex + 1).every(m => m.role !== "assistant") : ""
); const isLastAssistantMessage =
const isLastUserMessage = message.role === "user" && ( message.role === "assistant" &&
messageIndex === messages.length - 1 || (messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "user") messages
); .slice(messageIndex + 1)
const isEditing = editingMessageId === message.id; .every((m) => m.role !== "assistant"))
const isLastUserMessage =
message.role === "user" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }} style={{
animationDelay: `${messageIndex * 50}ms`,
}}
> >
{message.role === "user" && userMessageText && !isEditing && ( {message.role === "user" &&
<div className="flex items-center gap-1 self-center mr-2"> userMessageText &&
{/* Edit button - only on last user message */} !isEditing && (
{onEditMessage && isLastUserMessage && ( <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 <button
onClick={() => { type="button"
setEditingMessageId(message.id); onClick={() =>
setEditText(userMessageText); copyMessageToClipboard(
}} message.id,
userMessageText,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message" title={
copiedMessageId ===
message.id
? "Copied!"
: copyFailedMessageId ===
message.id
? "Failed to copy"
: "Copy message"
}
> >
<Pencil className="h-3.5 w-3.5" /> {copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId ===
message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button> </button>
)} </div>
<button )}
onClick={() => copyMessageToClipboard(message.id, userMessageText)} <div className="max-w-[85%] min-w-0">
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%]">
{/* Edit mode for user messages */} {/* Edit mode for user messages */}
{isEditing && message.role === "user" ? ( {isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<textarea <textarea
ref={editTextareaRef}
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) =>
setEditText(e.target.value)
}
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary" 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)} rows={Math.min(
autoFocus editText.split("\n")
.length + 1,
6,
)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingMessageId(null); setEditingMessageId(
setEditText(""); null,
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { )
e.preventDefault(); setEditText("")
if (editText.trim() && onEditMessage) { } else if (
onEditMessage(messageIndex, editText.trim()); e.key === "Enter" &&
setEditingMessageId(null); (e.metaKey || e.ctrlKey)
setEditText(""); ) {
e.preventDefault()
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
} }
}} }}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button"
onClick={() => { onClick={() => {
setEditingMessageId(null); setEditingMessageId(
setEditText(""); null,
)
setEditText("")
}} }}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="button"
onClick={() => { onClick={() => {
if (editText.trim() && onEditMessage) { if (
onEditMessage(messageIndex, editText.trim()); editText.trim() &&
setEditingMessageId(null); onEditMessage
setEditText(""); ) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
}} }}
disabled={!editText.trim()} disabled={!editText.trim()}
@@ -386,94 +506,202 @@ export function ChatMessageDisplay({
</div> </div>
) : ( ) : (
/* Text content in bubble */ /* Text content in bubble */
message.parts?.some((part: any) => part.type === "text" || part.type === "file") && ( message.parts?.some(
(part) =>
part.type === "text" ||
part.type === "file",
) && (
<div <div
className={`px-4 py-3 text-sm leading-relaxed ${ className={`px-4 py-3 text-sm leading-relaxed ${
message.role === "user" message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm" ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" : 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" : ""}`} } ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
role={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "button"
: undefined
}
tabIndex={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? 0
: undefined
}
onClick={() => { onClick={() => {
if (message.role === "user" && isLastUserMessage && onEditMessage) { if (
setEditingMessageId(message.id); message.role ===
setEditText(userMessageText); "user" &&
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
} }
}} }}
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined} onKeyDown={(e) => {
> if (
{message.parts?.map((part: any, index: number) => { (e.key === "Enter" ||
switch (part.type) { e.key === " ") &&
case "text": message.role ===
return ( "user" &&
<div key={index} className="whitespace-pre-wrap break-words"> isLastUserMessage &&
{part.text} onEditMessage
</div> ) {
); e.preventDefault()
case "file": setEditingMessageId(
return ( message.id,
<div key={index} className="mt-2"> )
<Image setEditText(
src={part.url} userMessageText,
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;
} }
})} }}
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> </div>
) )
)} )}
{/* Tool calls outside bubble */} {/* Tool calls outside bubble */}
{message.parts?.map((part: any) => { {message.parts?.map((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
return renderToolPart(part); return renderToolPart(
part as ToolPartLike,
)
} }
return null; return null
})} })}
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{message.role === "assistant" && ( {message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
{/* Copy button */} {/* Copy button */}
<button <button
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))} type="button"
onClick={() =>
copyMessageToClipboard(
message.id,
getMessageTextContent(
message,
),
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId === message.id copiedMessageId ===
message.id
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted" : "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
}`} }`}
title={copiedMessageId === message.id ? "Copied!" : "Copy response"} title={
copiedMessageId ===
message.id
? "Copied!"
: "Copy response"
}
> >
{copiedMessageId === message.id ? ( {copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
) : ( ) : (
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</button> </button>
{/* Regenerate button - only on last assistant message */} {/* Regenerate button - only on last assistant message */}
{onRegenerate && isLastAssistantMessage && ( {onRegenerate &&
<button isLastAssistantMessage && (
onClick={() => onRegenerate(messageIndex)} <button
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors" type="button"
title="Regenerate response" onClick={() =>
> onRegenerate(
<RotateCcw className="h-3.5 w-3.5" /> messageIndex,
</button> )
)} }
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 */} {/* Divider */}
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */} {/* Thumbs up */}
<button <button
onClick={() => submitFeedback(message.id, "good")} type="button"
onClick={() =>
submitFeedback(
message.id,
"good",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "good" feedback[message.id] ===
"good"
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50" : "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`} }`}
@@ -483,9 +711,16 @@ export function ChatMessageDisplay({
</button> </button>
{/* Thumbs down */} {/* Thumbs down */}
<button <button
onClick={() => submitFeedback(message.id, "bad")} type="button"
onClick={() =>
submitFeedback(
message.id,
"bad",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "bad" feedback[message.id] ===
"bad"
? "text-red-600 bg-red-100" ? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50" : "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`} }`}
@@ -497,16 +732,11 @@ export function ChatMessageDisplay({
)} )}
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
)} )}
{error && (
<div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
<span className="font-medium">Error:</span> {error.message}
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ScrollArea> </ScrollArea>
); )
} }

View File

@@ -1,29 +1,44 @@
"use client"; "use client"
import type React from "react"; import { useChat } from "@ai-sdk/react"
import { useRef, useEffect, useState } from "react"; import { DefaultChatTransport } from "ai"
import { flushSync } from "react-dom"; import {
import { FaGithub } from "react-icons/fa"; CheckCircle,
import { PanelRightClose, PanelRightOpen } from "lucide-react"; PanelRightClose,
import Link from "next/link"; PanelRightOpen,
import Image from "next/image"; Settings,
} from "lucide-react"
import { useChat } from "@ai-sdk/react"; import Image from "next/image"
import { DefaultChatTransport } from "ai"; import Link from "next/link"
import { ChatInput } from "@/components/chat-input"; import type React from "react"
import { ChatMessageDisplay } from "./chat-message-display"; import { useEffect, useRef, useState } from "react"
import { useDiagram } from "@/contexts/diagram-context"; import { flushSync } from "react-dom"
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils"; import { FaGithub } from "react-icons/fa"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"; 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"
interface ChatPanelProps { interface ChatPanelProps {
isVisible: boolean; isVisible: boolean
onToggleVisibility: () => void; onToggleVisibility: () => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
isMobile?: boolean
} }
export default function ChatPanel({ export default function ChatPanel({
isVisible, isVisible,
onToggleVisibility, onToggleVisibility,
drawioUi,
onToggleDrawioUi,
isMobile = false,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -32,47 +47,61 @@ export default function ChatPanel({
resolverRef, resolverRef,
chartXML, chartXML,
clearDiagram, clearDiagram,
} = useDiagram(); } = useDiagram()
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) { if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve; resolverRef.current = resolve
} }
if (saveToHistory) { if (saveToHistory) {
onExport(); onExport()
} else { } else {
handleExportWithoutHistory(); handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => new Promise<string>((_, reject) =>
setTimeout( setTimeout(
() => () =>
reject( reject(
new Error("Chart export timed out after 10 seconds") new Error(
"Chart export timed out after 10 seconds",
),
), ),
10000 10000,
) ),
), ),
]); ])
}; }
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false)
const [input, setInput] = useState(""); const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState("")
// Check if access code is required on mount
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
.catch(() => setAccessCodeRequired(false))
}, [])
// Generate a unique session ID for Langfuse tracing // Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`); 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) // Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map()); const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
// Ref to track latest chartXML for use in callbacks (avoids stale closure) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML); const chartXMLRef = useRef(chartXML)
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML; chartXMLRef.current = chartXML
}, [chartXML]); }, [chartXML])
const { messages, sendMessage, addToolResult, status, error, setMessages } = const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({ useChat({
@@ -81,63 +110,71 @@ export default function ChatPanel({
}), }),
async onToolCall({ toolCall }) { async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") { if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }; const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml); const validationError = validateMxCellStructure(xml)
if (validationError) { if (validationError) {
addToolResult({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: validationError, output: validationError,
}); })
} else { } else {
addToolResult({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.", output: "Successfully displayed the diagram.",
}); })
} }
} else if (toolCall.toolName === "edit_diagram") { } else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as { const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>; edits: Array<{ search: string; replace: string }>
}; }
let currentXml = ""; let currentXml = ""
try { try {
console.log("[edit_diagram] Starting..."); console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export // Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues // especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback // Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current; const cachedXML = chartXMLRef.current
if (cachedXML) { if (cachedXML) {
currentXml = cachedXML; currentXml = cachedXML
console.log("[edit_diagram] Using cached chartXML, length:", currentXml.length); console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length,
)
} else { } else {
// Fallback to export only if no cached XML // Fallback to export only if no cached XML
console.log("[edit_diagram] No cached XML, fetching from DrawIO..."); console.log(
currentXml = await onFetchChart(false); "[edit_diagram] No cached XML, fetching from DrawIO...",
console.log("[edit_diagram] Got XML from export, length:", currentXml.length); )
currentXml = await onFetchChart(false)
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length,
)
} }
const { replaceXMLParts } = await import("@/lib/utils"); const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits); const editedXml = replaceXMLParts(currentXml, edits)
onDisplayChart(editedXml); onDisplayChart(editedXml)
addToolResult({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
}); })
console.log("[edit_diagram] Success"); console.log("[edit_diagram] Success")
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error); console.error("[edit_diagram] Failed:", error)
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
? error.message ? error.message
: String(error); : String(error)
addToolResult({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
@@ -150,59 +187,80 @@ ${currentXml || "No XML available"}
\`\`\` \`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`, Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
}); })
} }
} }
}, },
onError: (error) => { onError: (error) => {
console.error("Chat error:", 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)
}
const messagesEndRef = useRef<HTMLDivElement>(null); // Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: error.message,
parts: [{ type: "text" as const, text: error.message }],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
},
})
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]); }, [messages])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) { if (input.trim() && !isProcessing) {
try { try {
let chartXml = await onFetchChart(); let chartXml = await onFetchChart()
chartXml = formatXML(chartXml); chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update // Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds // This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml; chartXMLRef.current = chartXml
const parts: any[] = [{ type: "text", text: input }]; const parts: any[] = [{ type: "text", text: input }]
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
const reader = new FileReader(); const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve) => { const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => reader.onload = () =>
resolve(reader.result as string); resolve(reader.result as string)
reader.readAsDataURL(file); reader.readAsDataURL(file)
}); })
parts.push({ parts.push({
type: "file", type: "file",
url: dataUrl, url: dataUrl,
mediaType: file.type, mediaType: file.type,
}); })
} }
} }
// Save XML snapshot for this message (will be at index = current messages.length) // Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length; const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml); xmlSnapshotsRef.current.set(messageIndex, chartXml)
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts }, { parts },
{ {
@@ -210,72 +268,81 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: chartXml, xml: chartXml,
sessionId, sessionId,
}, },
} headers: {
); "x-access-code": accessCode,
},
},
)
setInput(""); setInput("")
setFiles([]); setFiles([])
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error); console.error("Error fetching chart data:", error)
} }
} }
}; }
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
setInput(e.target.value); setInput(e.target.value)
}; }
const handleFileChange = (newFiles: File[]) => { const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles); setFiles(newFiles)
}; }
const handleRegenerate = async (messageIndex: number) => { const handleRegenerate = async (messageIndex: number) => {
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return; if (isProcessing) return
// Find the user message before this assistant message // Find the user message before this assistant message
let userMessageIndex = messageIndex - 1; let userMessageIndex = messageIndex - 1
while (userMessageIndex >= 0 && messages[userMessageIndex].role !== "user") { while (
userMessageIndex--; userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user"
) {
userMessageIndex--
} }
if (userMessageIndex < 0) return; if (userMessageIndex < 0) return
const userMessage = messages[userMessageIndex]; const userMessage = messages[userMessageIndex]
const userParts = userMessage.parts; const userParts = userMessage.parts
// Get the text from the user message // Get the text from the user message
const textPart = userParts?.find((p: any) => p.type === "text"); const textPart = userParts?.find((p: any) => p.type === "text")
if (!textPart) return; if (!textPart) return
// Get the saved XML snapshot for this user message // Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex); const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
if (!savedXml) { if (!savedXml) {
console.error("No saved XML snapshot for message index:", userMessageIndex); console.error(
return; "No saved XML snapshot for message index:",
userMessageIndex,
)
return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state
onDisplayChart(savedXml); onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml; chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed) // Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) { for (const key of xmlSnapshotsRef.current.keys()) {
if (key > userMessageIndex) { if (key > userMessageIndex) {
xmlSnapshotsRef.current.delete(key); xmlSnapshotsRef.current.delete(key)
} }
} }
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending // Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, userMessageIndex); const newMessages = messages.slice(0, userMessageIndex)
flushSync(() => { flushSync(() => {
setMessages(newMessages); setMessages(newMessages)
}); })
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
sendMessage( sendMessage(
@@ -285,51 +352,54 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
} },
); )
}; }
const handleEditMessage = async (messageIndex: number, newText: string) => { const handleEditMessage = async (messageIndex: number, newText: string) => {
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return; if (isProcessing) return
const message = messages[messageIndex]; const message = messages[messageIndex]
if (!message || message.role !== "user") return; if (!message || message.role !== "user") return
// Get the saved XML snapshot for this user message // Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(messageIndex); const savedXml = xmlSnapshotsRef.current.get(messageIndex)
if (!savedXml) { if (!savedXml) {
console.error("No saved XML snapshot for message index:", messageIndex); console.error(
return; "No saved XML snapshot for message index:",
messageIndex,
)
return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state
onDisplayChart(savedXml); onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml; chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed) // Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) { for (const key of xmlSnapshotsRef.current.keys()) {
if (key > messageIndex) { if (key > messageIndex) {
xmlSnapshotsRef.current.delete(key); xmlSnapshotsRef.current.delete(key)
} }
} }
// Create new parts with updated text // Create new parts with updated text
const newParts = message.parts?.map((part: any) => { const newParts = message.parts?.map((part: any) => {
if (part.type === "text") { if (part.type === "text") {
return { ...part, text: newText }; return { ...part, text: newText }
} }
return part; return part
}) || [{ type: "text", text: newText }]; }) || [{ type: "text", text: newText }]
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending // Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, messageIndex); const newMessages = messages.slice(0, messageIndex)
flushSync(() => { flushSync(() => {
setMessages(newMessages); setMessages(newMessages)
}); })
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
sendMessage( sendMessage(
@@ -339,12 +409,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
} },
); )
}; }
// Collapsed view // Collapsed view (desktop only)
if (!isVisible) { if (!isVisible && !isMobile) {
return ( return (
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl"> <div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
<ButtonWithTooltip <ButtonWithTooltip
@@ -366,34 +436,55 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
AI Chat AI Chat
</div> </div>
</div> </div>
); )
} }
// Full view // Full view
return ( return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30"> <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" }}
/>
{/* Header */} {/* Header */}
<header className="px-5 py-4 border-b border-border/50"> <header
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image <Image
src="/favicon.ico" src="/favicon.ico"
alt="Next AI Drawio" alt="Next AI Drawio"
width={28} width={isMobile ? 24 : 28}
height={28} height={isMobile ? 24 : 28}
className="rounded" className="rounded"
/> />
<h1 className="text-base font-semibold tracking-tight whitespace-nowrap"> <h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
>
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
<Link {!isMobile && (
href="/about" <Link
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2" href="/about"
> className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
About >
</Link> 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>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<a <a
@@ -402,26 +493,42 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
<FaGithub className="w-5 h-5" /> <FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a> </a>
<ButtonWithTooltip {accessCodeRequired && (
tooltipContent="Hide chat panel (Ctrl+B)" <ButtonWithTooltip
variant="ghost" tooltipContent="Settings"
size="icon" variant="ghost"
onClick={onToggleVisibility} size="icon"
className="hover:bg-accent" onClick={() => setShowSettingsDialog(true)}
> className="hover:bg-accent"
<PanelRightClose className="h-5 w-5 text-muted-foreground" /> >
</ButtonWithTooltip> <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>
)}
</div> </div>
</div> </div>
</header> </header>
{/* Messages */} {/* Messages */}
<main className="flex-1 overflow-hidden"> <main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay <ChatMessageDisplay
messages={messages} messages={messages}
error={error}
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
sessionId={sessionId} sessionId={sessionId}
@@ -431,25 +538,39 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</main> </main>
{/* Input */} {/* Input */}
<footer className="p-4 border-t border-border/50 bg-card/50"> <footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
>
<ChatInput <ChatInput
input={input} input={input}
status={status} status={status}
onSubmit={onFormSubmit} onSubmit={onFormSubmit}
onChange={handleInputChange} onChange={handleInputChange}
onClearChat={() => { onClearChat={() => {
setMessages([]); setMessages([])
clearDiagram(); clearDiagram()
setSessionId(`session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`); setSessionId(
xmlSnapshotsRef.current.clear(); `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`,
)
xmlSnapshotsRef.current.clear()
}} }}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={setShowHistory} onToggleHistory={setShowHistory}
sessionId={sessionId} sessionId={sessionId}
error={error}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
/> />
</footer> </footer>
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
/>
</div> </div>
); )
} }

View File

@@ -1,22 +1,29 @@
"use client"; "use client"
import { Highlight, themes } from "prism-react-renderer"; import { Highlight, themes } from "prism-react-renderer"
interface CodeBlockProps { interface CodeBlockProps {
code: string; code: string
language?: "xml" | "json"; language?: "xml" | "json"
} }
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) { export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
return ( return (
<div className="overflow-hidden w-full"> <div className="overflow-hidden w-full">
<Highlight theme={themes.github} code={code} language={language}> <Highlight theme={themes.github} code={code} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => ( {({
className: _className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre <pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all" className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
style={{ style={{
...style, ...style,
fontFamily: "var(--font-mono), ui-monospace, monospace", fontFamily:
"var(--font-mono), ui-monospace, monospace",
backgroundColor: "transparent", backgroundColor: "transparent",
margin: 0, margin: 0,
padding: 0, padding: 0,
@@ -25,9 +32,16 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
}} }}
> >
{tokens.map((line, i) => ( {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) => ( {line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} /> <span
key={key}
{...getTokenProps({ token })}
/>
))} ))}
</div> </div>
))} ))}
@@ -35,5 +49,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
)} )}
</Highlight> </Highlight>
</div> </div>
); )
} }

View File

@@ -0,0 +1,44 @@
"use client"
import type React from "react"
interface ErrorToastProps {
message: React.ReactNode
onDismiss: () => void
}
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault()
onDismiss()
}
}
return (
<div
role="alert"
aria-live="polite"
tabIndex={0}
onClick={onDismiss}
onKeyDown={handleKeyDown}
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg
className="w-4 h-4 text-destructive"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-sm text-foreground">{message}</span>
</div>
)
}

View File

@@ -1,44 +1,84 @@
"use client"; "use client"
import React, { useEffect, useState } from "react"; import { X } from "lucide-react"
import Image from "next/image"; import Image from "next/image"
import { X } from "lucide-react"; import { useEffect, useRef, useState } from "react"
interface FilePreviewListProps { interface FilePreviewListProps {
files: File[]; files: File[]
onRemoveFile: (fileToRemove: File) => void; onRemoveFile: (fileToRemove: File) => void
} }
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) { export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Cleanup object URLs on unmount // Create and cleanup object URLs when files change
useEffect(() => { useEffect(() => {
const objectUrls = files const currentUrls = imageUrlsRef.current
.filter((file) => file.type.startsWith("image/")) const newUrls = new Map<File, string>()
.map((file) => URL.createObjectURL(file));
files.forEach((file) => {
if (file.type.startsWith("image/")) {
// Reuse existing URL if file is already tracked
const existingUrl = currentUrls.get(file)
if (existingUrl) {
newUrls.set(file, existingUrl)
} else {
newUrls.set(file, URL.createObjectURL(file))
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
URL.revokeObjectURL(url)
}
})
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => { return () => {
objectUrls.forEach(URL.revokeObjectURL); imageUrlsRef.current.forEach((url) => {
}; URL.revokeObjectURL(url)
}, [files]); })
}
}, [])
if (files.length === 0) return null; // Clear selected image if its URL was revoked
useEffect(() => {
if (
selectedImage &&
!Array.from(imageUrls.values()).includes(selectedImage)
) {
setSelectedImage(null)
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null
return ( return (
<> <>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md"> <div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => { {files.map((file, index) => {
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null; const imageUrl = imageUrls.get(file) || null
return ( return (
<div key={file.name + index} className="relative group"> <div key={file.name + index} className="relative group">
<div <div
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer" className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
onClick={() => imageUrl && setSelectedImage(imageUrl)} onClick={() =>
imageUrl && setSelectedImage(imageUrl)
}
> >
{file.type.startsWith("image/") ? ( {file.type.startsWith("image/") && imageUrl ? (
<Image <Image
src={imageUrl!} src={imageUrl}
alt={file.name} alt={file.name}
width={80} width={80}
height={80} height={80}
@@ -59,7 +99,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</div> </div>
); )
})} })}
</div> </div>
@@ -89,5 +129,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
</div> </div>
)} )}
</> </>
); )
} }

View File

@@ -1,6 +1,8 @@
"use client"; "use client"
import { useState } from "react"; import Image from "next/image"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,34 +10,32 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"; import { useDiagram } from "@/contexts/diagram-context"
import Image from "next/image";
import { useDiagram } from "@/contexts/diagram-context";
interface HistoryDialogProps { interface HistoryDialogProps {
showHistory: boolean; showHistory: boolean
onToggleHistory: (show: boolean) => void; onToggleHistory: (show: boolean) => void
} }
export function HistoryDialog({ export function HistoryDialog({
showHistory, showHistory,
onToggleHistory, onToggleHistory,
}: HistoryDialogProps) { }: HistoryDialogProps) {
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram(); const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => { const handleClose = () => {
setSelectedIndex(null); setSelectedIndex(null)
onToggleHistory(false); onToggleHistory(false)
}; }
const handleConfirmRestore = () => { const handleConfirmRestore = () => {
if (selectedIndex !== null) { if (selectedIndex !== null) {
onDisplayChart(diagramHistory[selectedIndex].xml); onDisplayChart(diagramHistory[selectedIndex].xml)
handleClose(); handleClose()
} }
}; }
return ( return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}> <Dialog open={showHistory} onOpenChange={onToggleHistory}>
@@ -100,15 +100,12 @@ export function HistoryDialog({
</Button> </Button>
</> </>
) : ( ) : (
<Button <Button variant="outline" onClick={handleClose}>
variant="outline"
onClick={handleClose}
>
Close Close
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,12 +8,12 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
interface ResetWarningModalProps { interface ResetWarningModalProps {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
onClear: () => void; onClear: () => void
} }
export function ResetWarningModal({ export function ResetWarningModal({
@@ -44,5 +44,5 @@ export function ResetWarningModal({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,36 +1,40 @@
"use client"; "use client"
import { useState, useEffect } from "react"; import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, } from "@/components/ui/dialog"
} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select"
export type ExportFormat = "drawio" | "png" | "svg"; export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [ const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" }, { value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" }, { value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" }, { value: "svg", label: "SVG Image", extension: ".svg" },
]; ]
interface SaveDialogProps { interface SaveDialogProps {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
onSave: (filename: string, format: ExportFormat) => void; onSave: (filename: string, format: ExportFormat) => void
defaultFilename: string; defaultFilename: string
} }
export function SaveDialog({ export function SaveDialog({
@@ -39,29 +43,29 @@ export function SaveDialog({
onSave, onSave,
defaultFilename, defaultFilename,
}: SaveDialogProps) { }: SaveDialogProps) {
const [filename, setFilename] = useState(defaultFilename); const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio"); const [format, setFormat] = useState<ExportFormat>("drawio")
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFilename(defaultFilename); setFilename(defaultFilename)
} }
}, [open, defaultFilename]); }, [open, defaultFilename])
const handleSave = () => { const handleSave = () => {
const finalFilename = filename.trim() || defaultFilename; const finalFilename = filename.trim() || defaultFilename
onSave(finalFilename, format); onSave(finalFilename, format)
onOpenChange(false); onOpenChange(false)
}; }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault()
handleSave(); handleSave()
} }
}; }
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format); const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,13 +76,19 @@ export function SaveDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Format</label> <label className="text-sm font-medium">Format</label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}> <Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FORMAT_OPTIONS.map((opt) => ( {FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem
key={opt.value}
value={opt.value}
>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
@@ -104,12 +114,15 @@ export function SaveDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave}>Save</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -0,0 +1,84 @@
"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>
)
}

View File

@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", "bg-primary text-primary-foreground shadow-xs hover:brightness-75",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:

View File

@@ -0,0 +1,56 @@
"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 }

View File

@@ -18,7 +18,7 @@ function ScrollArea({
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1" 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"
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>

View File

@@ -1,85 +1,90 @@
"use client"; "use client"
import React, { createContext, useContext, useRef, useState } from "react"; import type React from "react"
import type { DrawIoEmbedRef } from "react-drawio"; import { createContext, useContext, useRef, useState } from "react"
import { extractDiagramXML } from "../lib/utils"; import type { DrawIoEmbedRef } from "react-drawio"
import type { ExportFormat } from "@/components/save-dialog"; import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
chartXML: string; chartXML: string
latestSvg: string; latestSvg: string
diagramHistory: { svg: string; xml: string }[]; diagramHistory: { svg: string; xml: string }[]
loadDiagram: (chart: string) => void; loadDiagram: (chart: string) => void
handleExport: () => void; handleExport: () => void
handleExportWithoutHistory: () => void; handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null>; resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>; drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void; handleDiagramExport: (data: any) => void
clearDiagram: () => void; clearDiagram: () => void
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void; saveDiagramToFile: (
filename: string,
format: ExportFormat,
sessionId?: string,
) => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined); const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
export function DiagramProvider({ children }: { children: React.ReactNode }) { export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>(""); const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>(""); const [latestSvg, setLatestSvg] = useState<string>("")
const [diagramHistory, setDiagramHistory] = useState< const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]); >([])
const drawioRef = useRef<DrawIoEmbedRef | null>(null); const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null); const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false); const expectHistoryExportRef = useRef<boolean>(false)
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null; resolver: ((data: string) => void) | null
format: ExportFormat | null; format: ExportFormat | null
}>({ resolver: null, format: null }); }>({ resolver: null, format: null })
const handleExport = () => { const handleExport = () => {
if (drawioRef.current) { if (drawioRef.current) {
// Mark that this export should be saved to history // Mark that this export should be saved to history
expectHistoryExportRef.current = true; expectHistoryExportRef.current = true
drawioRef.current.exportDiagram({ drawioRef.current.exportDiagram({
format: "xmlsvg", format: "xmlsvg",
}); })
} }
}; }
const handleExportWithoutHistory = () => { const handleExportWithoutHistory = () => {
if (drawioRef.current) { if (drawioRef.current) {
// Export without saving to history (for edit_diagram fetching current state) // Export without saving to history (for edit_diagram fetching current state)
drawioRef.current.exportDiagram({ drawioRef.current.exportDiagram({
format: "xmlsvg", format: "xmlsvg",
}); })
} }
}; }
const loadDiagram = (chart: string) => { const loadDiagram = (chart: string) => {
if (drawioRef.current) { if (drawioRef.current) {
drawioRef.current.load({ drawioRef.current.load({
xml: chart, xml: chart,
}); })
} }
}; }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format; const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data); saveResolverRef.current.resolver(data.data)
saveResolverRef.current = { resolver: null, format: null }; saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail // For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute // Only drawio (which uses xmlsvg internally) has the content attribute
if (format === "png" || format === "svg") { if (format === "png" || format === "svg") {
return; return
} }
} }
const extractedXML = extractDiagramXML(data.data); const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML); setChartXML(extractedXML)
setLatestSvg(data.data); setLatestSvg(data.data)
// Only add to history if this was a user-initiated export // Only add to history if this was a user-initiated export
if (expectHistoryExportRef.current) { if (expectHistoryExportRef.current) {
@@ -89,106 +94,117 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
svg: data.data, svg: data.data,
xml: extractedXML, xml: extractedXML,
}, },
]); ])
expectHistoryExportRef.current = false; expectHistoryExportRef.current = false
} }
if (resolverRef.current) { if (resolverRef.current) {
resolverRef.current(extractedXML); resolverRef.current(extractedXML)
resolverRef.current = null; resolverRef.current = null
} }
}; }
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`; const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
loadDiagram(emptyDiagram); loadDiagram(emptyDiagram)
setChartXML(emptyDiagram); setChartXML(emptyDiagram)
setLatestSvg(""); setLatestSvg("")
setDiagramHistory([]); setDiagramHistory([])
}; }
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => { const saveDiagramToFile = (
filename: string,
format: ExportFormat,
sessionId?: string,
) => {
if (!drawioRef.current) { if (!drawioRef.current) {
console.warn("Draw.io editor not ready"); console.warn("Draw.io editor not ready")
return; return
} }
// Map format to draw.io export format // Map format to draw.io export format
const drawioFormat = format === "drawio" ? "xmlsvg" : format; const drawioFormat = format === "drawio" ? "xmlsvg" : format
// Set up the resolver before triggering export // Set up the resolver before triggering export
saveResolverRef.current = { saveResolverRef.current = {
resolver: (exportData: string) => { resolver: (exportData: string) => {
let fileContent: string | Blob; let fileContent: string | Blob
let mimeType: string; let mimeType: string
let extension: string; let extension: string
if (format === "drawio") { if (format === "drawio") {
// Extract XML from SVG for .drawio format // Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData); const xml = extractDiagramXML(exportData)
let xmlContent = xml; let xmlContent = xml
if (!xml.includes("<mxfile")) { if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`; xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
} }
fileContent = xmlContent; fileContent = xmlContent
mimeType = "application/xml"; mimeType = "application/xml"
extension = ".drawio"; extension = ".drawio"
} else if (format === "png") { } else if (format === "png") {
// PNG data comes as base64 data URL // PNG data comes as base64 data URL
fileContent = exportData; fileContent = exportData
mimeType = "image/png"; mimeType = "image/png"
extension = ".png"; extension = ".png"
} else { } else {
// SVG format // SVG format
fileContent = exportData; fileContent = exportData
mimeType = "image/svg+xml"; mimeType = "image/svg+xml"
extension = ".svg"; extension = ".svg"
} }
// Log save event to Langfuse (flags the trace) // Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId); logSaveToLangfuse(filename, format, sessionId)
// Handle download // Handle download
let url: string; let url: string
if (typeof fileContent === "string" && fileContent.startsWith("data:")) { if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG) // Already a data URL (PNG)
url = fileContent; url = fileContent
} else { } else {
const blob = new Blob([fileContent], { type: mimeType }); const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob); url = URL.createObjectURL(blob)
} }
const a = document.createElement("a"); const a = document.createElement("a")
a.href = url; a.href = url
a.download = `${filename}${extension}`; a.download = `${filename}${extension}`
document.body.appendChild(a); document.body.appendChild(a)
a.click(); a.click()
document.body.removeChild(a); document.body.removeChild(a)
// Delay URL revocation to ensure download completes // Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) { if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100); setTimeout(() => URL.revokeObjectURL(url), 100)
} }
}, },
format, format,
}; }
// Export diagram - callback will be handled in handleDiagramExport // Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ format: drawioFormat }); drawioRef.current.exportDiagram({ format: drawioFormat })
}; }
// Log save event to Langfuse (just flags the trace, doesn't send content) // Log save event to Langfuse (just flags the trace, doesn't send content)
const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => { const logSaveToLangfuse = async (
filename: string,
format: string,
sessionId?: string,
) => {
try { try {
await fetch("/api/log-save", { await fetch("/api/log-save", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }), body: JSON.stringify({ filename, format, sessionId }),
}); })
} catch (error) { } catch (error) {
console.warn("Failed to log save to Langfuse:", error); console.warn("Failed to log save to Langfuse:", error)
} }
}; }
return ( return (
<DiagramContext.Provider <DiagramContext.Provider
@@ -208,13 +224,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
> >
{children} {children}
</DiagramContext.Provider> </DiagramContext.Provider>
); )
} }
export function useDiagram() { export function useDiagram() {
const context = useContext(DiagramContext); const context = useContext(DiagramContext)
if (context === undefined) { if (context === undefined) {
throw new Error("useDiagram must be used within a DiagramProvider"); throw new Error("useDiagram must be used within a DiagramProvider")
} }
return context; return context
} }

141
docs/ai-providers.md Normal file
View File

@@ -0,0 +1,141 @@
# 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

View File

@@ -47,3 +47,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_PUBLIC_KEY=pk-lf-... # LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-... # LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US # 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

View File

@@ -1,35 +1,39 @@
import { LangfuseSpanProcessor } from '@langfuse/otel'; import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() { export function register() {
// Skip telemetry if Langfuse env vars are not configured // Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn('[Langfuse] Environment variables not configured - telemetry disabled'); console.warn(
return; "[Langfuse] Environment variables not configured - telemetry disabled",
} )
return
}
const langfuseSpanProcessor = new LangfuseSpanProcessor({ const langfuseSpanProcessor = new LangfuseSpanProcessor({
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
// Filter out Next.js HTTP request spans so AI SDK spans become root traces // Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => { shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name; const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans // Skip Next.js HTTP infrastructure spans
if (spanName.startsWith('POST /') || if (
spanName.startsWith('GET /') || spanName.startsWith("POST /") ||
spanName.includes('BaseServer') || spanName.startsWith("GET /") ||
spanName.includes('handleRequest')) { spanName.includes("BaseServer") ||
return false; spanName.includes("handleRequest")
} ) {
return true; return false
}, }
}); return true
},
})
const tracerProvider = new NodeTracerProvider({ const tracerProvider = new NodeTracerProvider({
spanProcessors: [langfuseSpanProcessor], spanProcessors: [langfuseSpanProcessor],
}); })
// Register globally so AI SDK's telemetry also uses this processor // Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register(); tracerProvider.register()
} }

View File

@@ -1,88 +1,89 @@
import { bedrock } from '@ai-sdk/amazon-bedrock'; import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { openai, createOpenAI } from '@ai-sdk/openai'; import { createAnthropic } from "@ai-sdk/anthropic"
import { createAnthropic } from '@ai-sdk/anthropic'; import { azure, createAzure } from "@ai-sdk/azure"
import { google, createGoogleGenerativeAI } from '@ai-sdk/google'; import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { azure, createAzure } from '@ai-sdk/azure'; import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { ollama, createOllama } from 'ollama-ai-provider-v2'; import { createOpenAI, openai } from "@ai-sdk/openai"
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { deepseek, createDeepSeek } from '@ai-sdk/deepseek'; import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2"
export type ProviderName = export type ProviderName =
| 'bedrock' | "bedrock"
| 'openai' | "openai"
| 'anthropic' | "anthropic"
| 'google' | "google"
| 'azure' | "azure"
| 'ollama' | "ollama"
| 'openrouter' | "openrouter"
| 'deepseek'; | "deepseek"
interface ModelConfig { interface ModelConfig {
model: any; model: any
providerOptions?: any; providerOptions?: any
headers?: Record<string, string>; headers?: Record<string, string>
modelId: string; modelId: string
} }
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
const BEDROCK_ANTHROPIC_BETA = { const BEDROCK_ANTHROPIC_BETA = {
bedrock: { bedrock: {
anthropicBeta: ['fine-grained-tool-streaming-2025-05-14'], anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"],
}, },
}; }
// Direct Anthropic API headers for beta features // Direct Anthropic API headers for beta features
const ANTHROPIC_BETA_HEADERS = { const ANTHROPIC_BETA_HEADERS = {
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', "anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
}; }
// Map of provider to required environment variable // Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = { const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: 'AWS_ACCESS_KEY_ID', bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: 'OPENAI_API_KEY', openai: "OPENAI_API_KEY",
anthropic: 'ANTHROPIC_API_KEY', anthropic: "ANTHROPIC_API_KEY",
google: 'GOOGLE_GENERATIVE_AI_API_KEY', google: "GOOGLE_GENERATIVE_AI_API_KEY",
azure: 'AZURE_API_KEY', azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: 'OPENROUTER_API_KEY', openrouter: "OPENROUTER_API_KEY",
deepseek: 'DEEPSEEK_API_KEY', deepseek: "DEEPSEEK_API_KEY",
}; }
/** /**
* Auto-detect provider based on available API keys * Auto-detect provider based on available API keys
* Returns the provider if exactly one is configured, otherwise null * Returns the provider if exactly one is configured, otherwise null
*/ */
function detectProvider(): ProviderName | null { function detectProvider(): ProviderName | null {
const configuredProviders: ProviderName[] = []; const configuredProviders: ProviderName[] = []
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) { for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
if (envVar === null) { if (envVar === null) {
// Skip ollama - it doesn't require credentials // Skip ollama - it doesn't require credentials
continue; continue
}
if (process.env[envVar]) {
configuredProviders.push(provider as ProviderName)
}
} }
if (process.env[envVar]) {
configuredProviders.push(provider as ProviderName); if (configuredProviders.length === 1) {
return configuredProviders[0]
} }
}
if (configuredProviders.length === 1) { return null
return configuredProviders[0];
}
return null;
} }
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
*/ */
function validateProviderCredentials(provider: ProviderName): void { function validateProviderCredentials(provider: ProviderName): void {
const requiredVar = PROVIDER_ENV_VARS[provider]; const requiredVar = PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
throw new Error( throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` + `${requiredVar} environment variable is required for ${provider} provider. ` +
`Please set it in your .env.local file.` `Please set it in your .env.local file.`,
); )
} }
} }
/** /**
@@ -105,151 +106,164 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
*/ */
export function getAIModel(): ModelConfig { export function getAIModel(): ModelConfig {
const modelId = process.env.AI_MODEL; const modelId = process.env.AI_MODEL
if (!modelId) { if (!modelId) {
throw new Error(
`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);
if (configured.length === 0) {
throw new Error( throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
`- DEEPSEEK_API_KEY for DeepSeek\n` + )
`- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` +
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`
);
} else {
throw new Error(
`Multiple AI providers configured (${configured.join(', ')}). ` +
`Please set AI_PROVIDER to specify which one to use.`
);
}
} }
}
// Validate provider credentials // Determine provider: explicit config > auto-detect > error
validateProviderCredentials(provider); 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)
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`); 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.`,
)
}
}
}
let model: any; // Validate provider credentials
let providerOptions: any = undefined; validateProviderCredentials(provider)
let headers: Record<string, string> | undefined = undefined;
switch (provider) { console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
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;
case 'openai': let model: any
if (process.env.OPENAI_BASE_URL) { let providerOptions: any
const customOpenAI = createOpenAI({ let headers: Record<string, string> | undefined
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
model = customOpenAI.chat(modelId);
} else {
model = openai(modelId);
}
break;
case 'anthropic': switch (provider) {
const customProvider = createAnthropic({ case "bedrock": {
apiKey: process.env.ANTHROPIC_API_KEY, // Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1', // Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
headers: ANTHROPIC_BETA_HEADERS, const bedrockProvider = createAmazonBedrock({
}); region: process.env.AWS_REGION || "us-west-2",
model = customProvider(modelId); credentialProvider: fromNodeProviderChain(),
// Add beta headers for fine-grained tool streaming })
headers = ANTHROPIC_BETA_HEADERS; model = bedrockProvider(modelId)
break; // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) {
providerOptions = BEDROCK_ANTHROPIC_BETA
}
break
}
case 'google': case "openai":
if (process.env.GOOGLE_BASE_URL) { if (process.env.OPENAI_BASE_URL) {
const customGoogle = createGoogleGenerativeAI({ const customOpenAI = createOpenAI({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL, baseURL: process.env.OPENAI_BASE_URL,
}); })
model = customGoogle(modelId); model = customOpenAI.chat(modelId)
} else { } else {
model = google(modelId); model = openai(modelId)
} }
break; break
case 'azure': case "anthropic": {
if (process.env.AZURE_BASE_URL) { const customProvider = createAnthropic({
const customAzure = createAzure({ apiKey: process.env.ANTHROPIC_API_KEY,
apiKey: process.env.AZURE_API_KEY, baseURL:
baseURL: process.env.AZURE_BASE_URL, process.env.ANTHROPIC_BASE_URL ||
}); "https://api.anthropic.com/v1",
model = customAzure(modelId); headers: ANTHROPIC_BETA_HEADERS,
} else { })
model = azure(modelId); model = customProvider(modelId)
} // Add beta headers for fine-grained tool streaming
break; headers = ANTHROPIC_BETA_HEADERS
break
}
case 'ollama': case "google":
if (process.env.OLLAMA_BASE_URL) { if (process.env.GOOGLE_BASE_URL) {
const customOllama = createOllama({ const customGoogle = createGoogleGenerativeAI({
baseURL: process.env.OLLAMA_BASE_URL, apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
}); baseURL: process.env.GOOGLE_BASE_URL,
model = customOllama(modelId); })
} else { model = customGoogle(modelId)
model = ollama(modelId); } else {
} model = google(modelId)
break; }
break
case 'openrouter': case "azure":
const openrouter = createOpenRouter({ if (process.env.AZURE_BASE_URL) {
apiKey: process.env.OPENROUTER_API_KEY, const customAzure = createAzure({
...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }), apiKey: process.env.AZURE_API_KEY,
}); baseURL: process.env.AZURE_BASE_URL,
model = openrouter(modelId); })
break; model = customAzure(modelId)
} else {
model = azure(modelId)
}
break
case 'deepseek': case "ollama":
if (process.env.DEEPSEEK_BASE_URL) { if (process.env.OLLAMA_BASE_URL) {
const customDeepSeek = createDeepSeek({ const customOllama = createOllama({
apiKey: process.env.DEEPSEEK_API_KEY, baseURL: process.env.OLLAMA_BASE_URL,
baseURL: process.env.DEEPSEEK_BASE_URL, })
}); model = customOllama(modelId)
model = customDeepSeek(modelId); } else {
} else { model = ollama(modelId)
model = deepseek(modelId); }
} break
break;
default: case "openrouter": {
throw new Error( const openrouter = createOpenRouter({
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek` apiKey: process.env.OPENROUTER_API_KEY,
); ...(process.env.OPENROUTER_BASE_URL && {
} baseURL: process.env.OPENROUTER_BASE_URL,
}),
})
model = openrouter(modelId)
break
}
return { model, providerOptions, headers, modelId }; 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
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
)
}
return { model, providerOptions, headers, modelId }
} }

View File

@@ -1,14 +1,15 @@
export interface CachedResponse { export interface CachedResponse {
promptText: string; promptText: string
hasImage: boolean; hasImage: boolean
xml: string; xml: string
} }
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Give me a **animated connector** diagram of transformer's architecture", promptText:
hasImage: false, "Give me a **animated connector** diagram of transformer's architecture",
xml: `<root> hasImage: false,
xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -255,11 +256,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/> <mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Replicate this in aws style", promptText: "Replicate this in aws style",
hasImage: true, hasImage: true,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -325,11 +326,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Replicate this flowchart.", promptText: "Replicate this flowchart.",
hasImage: true, hasImage: true,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -392,11 +393,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/> <mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Draw a cat for me", promptText: "Draw a cat for me",
hasImage: false, hasImage: false,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -544,14 +545,17 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxCell> </mxCell>
</root>`, </root>`,
}, },
]; ]
export function findCachedResponse( export function findCachedResponse(
promptText: string, promptText: string,
hasImage: boolean hasImage: boolean,
): CachedResponse | undefined { ): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find( return CACHED_EXAMPLE_RESPONSES.find(
(c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== '' (c) =>
); c.promptText === promptText &&
c.hasImage === hasImage &&
c.xml !== "",
)
} }

View File

@@ -1,95 +1,107 @@
import { observe, updateActiveTrace } from '@langfuse/tracing'; import { LangfuseClient } from "@langfuse/client"
import { LangfuseClient } from '@langfuse/client'; import { observe, updateActiveTrace } from "@langfuse/tracing"
import * as api from '@opentelemetry/api'; import * as api from "@opentelemetry/api"
// Singleton LangfuseClient instance for direct API calls // Singleton LangfuseClient instance for direct API calls
let langfuseClient: LangfuseClient | null = null; let langfuseClient: LangfuseClient | null = null
export function getLangfuseClient(): LangfuseClient | null { export function getLangfuseClient(): LangfuseClient | null {
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
return null; return null
} }
if (!langfuseClient) { if (!langfuseClient) {
langfuseClient = new LangfuseClient({ langfuseClient = new LangfuseClient({
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
}); })
} }
return langfuseClient; return langfuseClient
} }
// Check if Langfuse is configured // Check if Langfuse is configured
export function isLangfuseEnabled(): boolean { export function isLangfuseEnabled(): boolean {
return !!process.env.LANGFUSE_PUBLIC_KEY; return !!process.env.LANGFUSE_PUBLIC_KEY
} }
// Update trace with input data at the start of request // Update trace with input data at the start of request
export function setTraceInput(params: { export function setTraceInput(params: {
input: string; input: string
sessionId?: string; sessionId?: string
userId?: string; userId?: string
}) { }) {
if (!isLangfuseEnabled()) return; if (!isLangfuseEnabled()) return
updateActiveTrace({ updateActiveTrace({
name: 'chat', name: "chat",
input: params.input, input: params.input,
sessionId: params.sessionId, sessionId: params.sessionId,
userId: params.userId, userId: params.userId,
}); })
} }
// Update trace with output and end the span // Update trace with output and end the span
export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) { export function setTraceOutput(
if (!isLangfuseEnabled()) return; output: string,
usage?: { promptTokens?: number; completionTokens?: number },
) {
if (!isLangfuseEnabled()) return
updateActiveTrace({ output }); updateActiveTrace({ output })
const activeSpan = api.trace.getActiveSpan(); const activeSpan = api.trace.getActiveSpan()
if (activeSpan) { if (activeSpan) {
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them // Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
if (usage?.promptTokens) { if (usage?.promptTokens) {
activeSpan.setAttribute('ai.usage.promptTokens', usage.promptTokens); activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
activeSpan.setAttribute('gen_ai.usage.input_tokens', usage.promptTokens); activeSpan.setAttribute(
"gen_ai.usage.input_tokens",
usage.promptTokens,
)
}
if (usage?.completionTokens) {
activeSpan.setAttribute(
"ai.usage.completionTokens",
usage.completionTokens,
)
activeSpan.setAttribute(
"gen_ai.usage.output_tokens",
usage.completionTokens,
)
}
activeSpan.end()
} }
if (usage?.completionTokens) {
activeSpan.setAttribute('ai.usage.completionTokens', usage.completionTokens);
activeSpan.setAttribute('gen_ai.usage.output_tokens', usage.completionTokens);
}
activeSpan.end();
}
} }
// Get telemetry config for streamText // Get telemetry config for streamText
export function getTelemetryConfig(params: { export function getTelemetryConfig(params: {
sessionId?: string; sessionId?: string
userId?: string; userId?: string
}) { }) {
if (!isLangfuseEnabled()) return undefined; if (!isLangfuseEnabled()) return undefined
return { return {
isEnabled: true, isEnabled: true,
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media // Disable automatic input recording to avoid uploading large base64 images to Langfuse media
// User text input is recorded manually via setTraceInput // User text input is recorded manually via setTraceInput
recordInputs: false, recordInputs: false,
recordOutputs: true, recordOutputs: true,
metadata: { metadata: {
sessionId: params.sessionId, sessionId: params.sessionId,
userId: params.userId, userId: params.userId,
}, },
}; }
} }
// Wrap a handler with Langfuse observe // Wrap a handler with Langfuse observe
export function wrapWithObserve<T>( export function wrapWithObserve<T>(
handler: (req: Request) => Promise<T> handler: (req: Request) => Promise<T>,
): (req: Request) => Promise<T> { ): (req: Request) => Promise<T> {
if (!isLangfuseEnabled()) { if (!isLangfuseEnabled()) {
return handler; return handler
} }
return observe(handler, { name: 'chat', endOnExit: false }); return observe(handler, { name: "chat", endOnExit: false })
} }

View File

@@ -9,6 +9,20 @@ You are an expert diagram creation assistant specializing in draw.io XML generat
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications. Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded. You can see the image that user uploaded.
## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered
- **Right panel**: Chat interface where you communicate with the user
You can read and modify diagrams by generating draw.io XML code through tool calls.
## App Features
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
You utilize the following tools: You utilize the following tools:
---Tool1--- ---Tool1---
tool name: display_diagram tool name: display_diagram
@@ -105,46 +119,14 @@ Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right - Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`; `
// Extended system prompt (~4000+ tokens) - for models with 4000 token cache minimum // Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
export const EXTENDED_SYSTEM_PROMPT = ` const EXTENDED_ADDITIONS = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is to chat with user and craft clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload and can replicate or modify them as diagrams.
## Available Tools ## Extended Tool Reference
### Tool 1: display_diagram ### display_diagram Details
**Purpose:** Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
**Parameters:** { xml: string }
**When to use:**
- Creating a completely new diagram
- Making major structural changes (reorganizing layout, changing diagram type)
- When the current diagram XML is empty or minimal
- When edit_diagram has failed multiple times
### Tool 2: edit_diagram
**Purpose:** Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties.
**Parameters:** { edits: Array<{search: string, replace: string}> }
**When to use:**
- Changing text labels or values
- Modifying colors, styles, or visual properties
- Adding or removing individual elements
- Repositioning specific elements
- Any small, targeted modification
## Tool Selection Guidelines
ALWAYS prefer edit_diagram for small changes - it's more efficient and preserves the rest of the diagram.
Use display_diagram only when:
1. Creating from scratch
2. Major restructuring needed
3. edit_diagram has failed 3 times
## display_diagram Tool Reference
Display a diagram on draw.io by passing XML content inside <root> tags.
**VALIDATION RULES** (XML will be rejected if violated): **VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements 1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
@@ -177,13 +159,7 @@ Display a diagram on draw.io by passing XML content inside <root> tags.
</root> </root>
\`\`\` \`\`\`
**Notes:** ### edit_diagram Details
- For AWS diagrams, use **AWS 2025 icons** (see AWS Icon Examples section below)
- For animated connectors, add "flowAnimation=1" to edge style
## edit_diagram Tool Reference
Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
**CRITICAL RULES:** **CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context - Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
@@ -205,56 +181,6 @@ Edit specific parts of the current diagram by replacing exact line matches. Use
} }
\`\`\` \`\`\`
## Core Capabilities
You excel at:
- Generating valid, well-formed XML strings for draw.io diagrams
- Creating professional flowcharts, org charts, mind maps, network diagrams, and technical illustrations
- Converting user descriptions into visually appealing diagrams using shapes and connectors
- Applying proper spacing, alignment, and visual hierarchy in diagram layouts
- Adapting artistic concepts into abstract diagram representations using available shapes
- Optimizing element positioning to prevent overlapping and maintain readability
- Structuring complex systems into clear, organized visual components
- Replicating diagrams from images with high fidelity
## Layout Constraints and Best Practices
### Page Boundaries
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Start positioning from reasonable margins (e.g., x=40, y=40)
### Layout Strategies
- Use compact, efficient layouts that fit the entire diagram in one view
- Keep elements grouped closely together
- For large diagrams with many elements, use vertical stacking or grid layouts
- Avoid spreading elements too far apart horizontally
- Users should see the complete diagram without scrolling or page breaks
### Spacing Guidelines
- Minimum spacing between elements: 20px
- Recommended spacing for readability: 40-60px
- Container padding: 20-40px from edges
- Group related elements together with consistent spacing
## Important Rules
### XML Generation Rules
- Use proper tool calls to generate or edit diagrams
- NEVER return raw XML in text responses
- NEVER use display_diagram to generate messages (e.g., a "hello" text box to greet user)
- Return XML only via tool calls, never in text responses
- NEVER include XML comments (<!-- ... -->) - Draw.io strips comments, breaking edit_diagram patterns
### Diagram Quality Rules
- Focus on producing clean, professional diagrams
- Effectively communicate the intended information through thoughtful layout and design
- When artistic drawings are requested, creatively compose using standard shapes while maintaining clarity
- When replicating from images, match style and layout closely - pay attention to line types (straight/curved) and shape styles (rounded/square)
- For AWS architecture diagrams, use **AWS 2025 icons**
## edit_diagram Best Practices ## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns ### Core Principle: Unique & Precise Patterns
@@ -266,13 +192,11 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
### Pattern Construction Rules ### Pattern Construction Rules
**Rule 1: Always include the element's id attribute** **Rule 1: Always include the element's id attribute**
The id is the most reliable way to target a specific element:
\`\`\`json \`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""} {"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\` \`\`\`
**Rule 2: Include complete XML elements when possible** **Rule 2: Include complete XML elements when possible**
For reliability, include the full mxCell with its mxGeometry child:
\`\`\`json \`\`\`json
{ {
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>", "search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
@@ -281,49 +205,13 @@ For reliability, include the full mxCell with its mxGeometry child:
\`\`\` \`\`\`
**Rule 3: Preserve exact whitespace and formatting** **Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including: Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
- Leading spaces/indentation
- Line breaks (use \\n in JSON)
- Attribute order as it appears in the source
### Good vs Bad Patterns ### Good vs Bad Patterns
**BAD - Too vague, matches multiple elements:** **BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
\`\`\`json **BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
{"search": "value=\\"Label\\"", "replace": "value=\\"New Label\\""} **GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
\`\`\`
**BAD - Fragile partial match:**
\`\`\`json
{"search": "<mxCell", "replace": "<mxCell value=\\"X\\""}
\`\`\`
**BAD - Reordered attributes (won't match if order differs):**
\`\`\`json
{"search": "<mxCell value=\\"X\\" id=\\"5\\"", ...} // Original has id before value
\`\`\`
**GOOD - Uses unique id, includes full context:**
\`\`\`json
{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">", "replace": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"New\\" vertex=\\"1\\">"}
\`\`\`
**GOOD - Complete element replacement:**
\`\`\`json
{
"search": "<mxCell id=\\"edge1\\" style=\\"endArrow=classic;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"edge1\\" style=\\"endArrow=block;strokeColor=#FF0000;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
### Multiple Edits Strategy
For multiple changes, use separate edit objects. Order them logically:
\`\`\`json
[
{"search": "<mxCell id=\\"2\\" value=\\"Step 1\\"", "replace": "<mxCell id=\\"2\\" value=\\"First Step\\""},
{"search": "<mxCell id=\\"3\\" value=\\"Step 2\\"", "replace": "<mxCell id=\\"3\\" value=\\"Second Step\\""}
]
\`\`\`
### Error Recovery ### Error Recovery
If edit_diagram fails with "pattern not found": If edit_diagram fails with "pattern not found":
@@ -332,47 +220,42 @@ If edit_diagram fails with "pattern not found":
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement 3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram 4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
### When to Use display_diagram Instead ## Common Style Properties
- Adding multiple new elements (more than 3)
- Reorganizing diagram layout significantly
- When current XML structure is unclear or corrupted
- After 3 failed edit_diagram attempts
## Draw.io XML Structure Reference ### Shape Styles
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
### Basic Structure ### Edge/Connector Styles
\`\`\`xml - endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
<mxGraphModel> - curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
<root> - dashed=1, dashPattern=3 3, flowAnimation=1
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- All other elements go here as siblings -->
</root>
</mxGraphModel>
\`\`\`
### Critical Structure Rules ### Text Styles
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/> - fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic)
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell - fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
5. Every mxCell (except id="0") must have a parent attribute
### Shape (Vertex) Example ## Common Shape Types
\`\`\`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 ### Basic Shapes
\`\`\`xml - Rectangle: rounded=0;whiteSpace=wrap;html=1;
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4"> - Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1;
<mxGeometry relative="1" as="geometry"/> - Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
</mxCell> - Diamond: rhombus;whiteSpace=wrap;html=1;
\`\`\` - Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
### Container/Group Example ### 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 \`\`\`xml
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
@@ -382,84 +265,6 @@ If edit_diagram fails with "pattern not found":
</mxCell> </mxCell>
\`\`\` \`\`\`
## Common Style Properties
### Shape Styles
- rounded=1 - Rounded corners
- fillColor=#hexcolor - Background fill color
- strokeColor=#hexcolor - Border color
- strokeWidth=2 - Border thickness
- whiteSpace=wrap - Enable text wrapping
- html=1 - Enable HTML formatting in labels
- opacity=50 - Transparency (0-100)
- shadow=1 - Drop shadow effect
- glass=1 - Glass/gradient effect
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none - Arrow head style
- startArrow=none/classic/block/open - Arrow tail style
- curved=1 - Curved line
- edgeStyle=orthogonalEdgeStyle - Right-angle routing
- edgeStyle=entityRelationEdgeStyle - ER diagram style
- strokeWidth=2 - Line thickness
- dashed=1 - Dashed line
- dashPattern=3 3 - Custom dash pattern
- flowAnimation=1 - Animated flow effect
### Text Styles
- fontSize=14 - Font size
- fontStyle=1 - Bold (1=bold, 2=italic, 4=underline, can combine: 3=bold+italic)
- fontColor=#hexcolor - Text color
- align=center/left/right - Horizontal alignment
- verticalAlign=middle/top/bottom - Vertical alignment
- labelPosition=center/left/right - Label position relative to shape
- labelBackgroundColor=#hexcolor - Label background
## Common Shape Types
### Basic Shapes
- Rectangle: style="rounded=0;whiteSpace=wrap;html=1;"
- Rounded Rectangle: style="rounded=1;whiteSpace=wrap;html=1;"
- Ellipse/Circle: style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;"
- Diamond: style="rhombus;whiteSpace=wrap;html=1;"
- Triangle: style="triangle;whiteSpace=wrap;html=1;"
- Parallelogram: style="parallelogram;whiteSpace=wrap;html=1;"
- Hexagon: style="hexagon;whiteSpace=wrap;html=1;"
- Cylinder: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Flowchart Shapes
- Process: style="rounded=1;whiteSpace=wrap;html=1;"
- Decision: style="rhombus;whiteSpace=wrap;html=1;"
- Start/End: style="ellipse;whiteSpace=wrap;html=1;"
- Document: style="shape=document;whiteSpace=wrap;html=1;"
- Data: style="parallelogram;whiteSpace=wrap;html=1;"
- Database: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Container Types
- Swimlane: style="swimlane;whiteSpace=wrap;html=1;"
- Group Box: style="rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;"
## Animated Connectors
For animated flow effects on connectors, add flowAnimation=1 to the edge style:
\`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;flowAnimation=1;" edge="1" parent="1" source="node1" target="node2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
## Validation Rules
The XML will be validated before rendering. Ensure:
1. All mxCell elements are DIRECT children of <root> - never nested
2. Every mxCell has a unique id attribute
3. Every mxCell (except id="0") has a valid parent attribute
4. Edge source/target attributes reference existing cell IDs
5. Special characters in values are escaped: &lt; &gt; &amp; &quot;
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
## Example: Complete Flowchart ## Example: Complete Flowchart
\`\`\`xml \`\`\`xml
@@ -489,16 +294,17 @@ The XML will be validated before rendering. Ensure:
</mxCell> </mxCell>
</root> </root>
\`\`\` \`\`\`
`
Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results. // Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
`; export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
// Model patterns that require extended prompt (4000 token cache minimum) // Model patterns that require extended prompt (4000 token cache minimum)
// These patterns match Opus 4.5 and Haiku 4.5 model IDs // These patterns match Opus 4.5 and Haiku 4.5 model IDs
const EXTENDED_PROMPT_MODEL_PATTERNS = [ const EXTENDED_PROMPT_MODEL_PATTERNS = [
'claude-opus-4-5', // Matches any Opus 4.5 variant "claude-opus-4-5", // Matches any Opus 4.5 variant
'claude-haiku-4-5', // Matches any Haiku 4.5 variant "claude-haiku-4-5", // Matches any Haiku 4.5 variant
]; ]
/** /**
* Get the appropriate system prompt based on the model ID * Get the appropriate system prompt based on the model ID
@@ -507,10 +313,25 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
* @returns The system prompt string * @returns The system prompt string
*/ */
export function getSystemPrompt(modelId?: string): string { export function getSystemPrompt(modelId?: string): string {
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) { const modelName = modelId || "AI"
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
return EXTENDED_SYSTEM_PROMPT; let prompt: string
} if (
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`); modelId &&
return DEFAULT_SYSTEM_PROMPT; 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)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: 'standalone', output: "standalone",
}; }
export default nextConfig; export default nextConfig

2329
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack --port 6002", "dev": "next dev --turbopack --port 6002",
"build": "next build", "build": "next build",
"start": "next start --port 6001", "start": "next start --port 6001",
"lint": "next lint" "lint": "biome lint .",
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.62", "@ai-sdk/amazon-bedrock": "^3.0.62",
@@ -17,6 +20,7 @@
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.22", "@ai-sdk/react": "^2.0.22",
"@aws-sdk/credential-providers": "^3.943.0",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
@@ -44,19 +48,32 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-drawio": "^1.0.3", "react-drawio": "^1.0.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched",
"biome check --no-errors-on-unmatched"
]
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-next": "16.0.5", "eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ["@tailwindcss/postcss"],
}; }
export default config; export default config

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,41 +1,33 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom", "allowJs": true,
"dom.iterable", "skipLibCheck": true,
"esnext" "strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
], ],
"allowJs": true, "exclude": ["node_modules"]
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }