Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
015537e97d feat: expand PR review scope to catch more issues
- Add categories for scope creep, suspicious .gitignore additions, UI inconsistencies
- Change from 'be very selective' to 'report ALL issues found'
- Simplify DO NOT comment list to allow more actionable feedback
2025-11-29 11:36:42 +09:00
61 changed files with 2119 additions and 15882 deletions

View File

@@ -1,60 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
.next
out
dist
build
# Testing
coverage
.nyc_output
# Environment variables
.env
.env*.local
.env.local
.env.development.local
.env.test.local
.env.production.local
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Operating System
.DS_Store
Thumbs.db
# Documentation
README.md
*.md
!env.example
# CI/CD
.github
.gitlab-ci.yml
.travis.yml
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
# Other
*.log
.cache
.turbo

View File

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

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: dayuanjiang
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

114
.github/workflows/claude-code.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1
- name: Run Claude Code
uses: anthropics/claude-code-action@v1
with:
use_bedrock: "true"
github_token: ${{ secrets.GITHUB_TOKEN }}
pr-review:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1
- name: Run Claude Code PR Review
uses: anthropics/claude-code-action@v1
with:
use_bedrock: "true"
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
This is a personal project - an AI-powered draw.io diagram generator built with:
- Next.js 15 with React 19
- Vercel AI SDK (streamText, useChat, tool calling)
- Multiple AI providers: Bedrock, Anthropic, OpenAI, Google, Azure, OpenRouter, Ollama
First, check previous review comments from github-actions bot using `gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments`.
For each previous comment:
- If the issue is fixed in the current code, resolve the comment thread using:
`gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "THREAD_ID"}) { thread { isResolved } } }'`
Get the thread ID from the comment's node_id field.
- If the issue still exists, leave it alone
Then review the current diff for NEW issues only:
Review this PR for these issues (report ALL that apply):
1. Bugs that would cause runtime errors or broken functionality
2. Security issues (exposed secrets, API key leaks)
3. AI SDK misuse - specifically check for:
- Client-side: Should use useChat/useCompletion/useObject hooks, NOT raw fetch()
- Server-side: Should use streamText/generateText/streamObject/generateObject
- Message handling: Access message.parts array, not legacy content property
- Tool definitions: Must use Zod schemas for inputSchema
- Status handling: Check status (submitted/streaming/ready/error) before actions
- Stream cleanup: Call stop() when aborting streams
4. Unrelated changes that should be in separate PRs (scope creep)
5. Suspicious .gitignore additions or accidentally committed files
6. UI/UX inconsistencies (e.g., alignment issues)
When reviewing AI SDK usage, fetch https://ai-sdk.dev/docs/ to verify correct patterns.
Key doc pages: /docs/ai-sdk-ui/chatbot, /docs/ai-sdk-core/generating-text, /docs/ai-sdk-core/tools-and-tool-calling
DO NOT comment on:
- Minor performance optimizations
- Code style preferences (unless clearly wrong)
- Type annotations that don't affect functionality
Use `mcp__github_inline_comment__create_inline_comment` for inline comments.
Report ALL issues found - create multiple inline comments if needed. Only say "LGTM" if there are truly no issues.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api:*),WebFetch(domain:ai-sdk.dev)"

View File

@@ -1,66 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
- master
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- master
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

1
.gitignore vendored
View File

@@ -41,4 +41,3 @@ yarn-error.log*
next-env.d.ts
push-via-ec2.sh
.claude/settings.local.json
.playwright-mcp/

View File

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

23
.vscode/settings.json vendored
View File

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

View File

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

View File

@@ -1,55 +0,0 @@
# Multi-stage Dockerfile for Next.js
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci
# Stage 2: Build application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application (standalone mode)
RUN npm run build
# Stage 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
# Copy standalone build output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start the application
CMD ["node", "server.js"]

190
LICENSE
View File

@@ -1,190 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Dayuan Jiang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

102
README.md
View File

@@ -1,24 +1,11 @@
# Next AI Draw.io
<div align="center">
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
English | [中文](./README_CN.md) | [日本語](./README_JA.md)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Next.js](https://img.shields.io/badge/Next.js-15.x-black)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[🚀 Live Demo](https://next-ai-drawio.jiang.jp/)
</div>
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
A next.js web application that integrates AI capabilities with draw.io diagrams. This app allows you to create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
Demo site: [https://next-ai-draw-io.vercel.app](https://next-ai-draw-io.vercel.app)
## Features
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
@@ -34,13 +21,6 @@ Here are some example prompts and their generated diagrams:
<div align="center">
<table width="100%">
<tr>
<td colspan="2" valign="top" align="center">
<strong>Animated transformer connectors</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCP architecture diagram</strong><br />
@@ -60,9 +40,16 @@ Here are some example prompts and their generated diagrams:
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Animated transformer connectors</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" />
</td>
</tr>
<tr>
<td colspan="2" valign="top" align="center">
<strong>Cat sketch prompt</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="260" />
</td>
</tr>
</table>
@@ -73,7 +60,7 @@ Here are some example prompts and their generated diagrams:
The application uses the following technologies:
- **Next.js**: For the frontend framework and routing
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
- **@ai-sdk/react**: For the chat interface and AI interactions
- **react-drawio**: For diagram representation and manipulation
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
@@ -81,44 +68,16 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Multi-Provider Support
- AWS Bedrock (default)
- OpenAI
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-4o, Gemini 2.0, and DeepSeek V3/R1.
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
## Getting Started
### Run with Docker (Recommended)
If you just want to run it locally, the best way is to use Docker.
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
Then run:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
### Installation
1. Clone the repository:
@@ -146,14 +105,11 @@ cp env.example .env.local
Edit `.env.local` and configure your chosen provider:
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama)
- Set `AI_MODEL` to the specific model you want to use
- Add the required API keys for your provider
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
4. Run the development server:
@@ -177,19 +133,14 @@ Be sure to **set the environment variables** in the Vercel dashboard as you did
## Project Structure
```
app/ # Next.js App Router
api/chat/ # Chat API endpoint with AI tools
page.tsx # Main page with DrawIO embed
app/ # Next.js application routes and pages
extract_xml.ts # Utilities for XML processing
components/ # React components
chat-panel.tsx # Chat interface with diagram control
chat-input.tsx # User input component with file upload
history-dialog.tsx # Diagram version history viewer
chat-input.tsx # User input component for AI interaction
chatPanel.tsx # Chat interface with diagram control
ui/ # UI components (buttons, cards, etc.)
contexts/ # React context providers
diagram-context.tsx # Global diagram state management
lib/ # Utility functions and helpers
ai-providers.ts # Multi-provider AI configuration
utils.ts # XML processing and conversion utilities
utils.ts # General utilities including XML conversion
public/ # Static assets including example images
```
@@ -198,19 +149,16 @@ public/ # Static assets including example images
- [x] Allow the LLM to modify the XML instead of generating it from scratch everytime.
- [x] Improve the smoothness of shape streaming updates.
- [x] Add multiple AI provider support (OpenAI, Anthropic, Google, Azure, Ollama)
- [x] Solve the bug that generation will fail for session that longer than 60s.
- [ ] Add API config on the UI.
- [ ] Solve the bug that generation will fail for session that longer than 60s.
## License
This project is licensed under the MIT License.
## Support & Contact
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
- Email: me[at]jiang.jp
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
---

View File

@@ -1,216 +0,0 @@
# Next AI Draw.io
<div align="center">
**AI驱动的图表创建工具 - 对话、绘制、可视化**
[English](./README.md) | 中文 | [日本語](./README_JA.md)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Next.js](https://img.shields.io/badge/Next.js-15.x-black)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[🚀 在线演示](https://next-ai-drawio.jiang.jp/)
</div>
一个集成了AI功能的Next.js网页应用与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 功能特性
- **LLM驱动的图表创建**利用大语言模型通过自然语言命令直接创建和操作draw.io图表
- **基于图像的图表复制**上传现有图表或图像让AI自动复制和增强
- **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**与AI实时对话来完善您的图表
- **AWS架构图支持**专门支持生成AWS架构图
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## **示例**
以下是一些示例提示词及其生成的图表:
<div align="center">
<table width="100%">
<tr>
<td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br />
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
<img src="./public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCP架构图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="./public/gcp_demo.svg" alt="GCP架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>AWS架构图</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="./public/aws_demo.svg" alt="AWS架构图" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Azure架构图</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="./public/azure_demo.svg" alt="Azure架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫咪素描</strong><br />
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
<img src="./public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td>
</tr>
</table>
</div>
## 工作原理
本应用使用以下技术:
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**`ai` + `@ai-sdk/*`用于流式AI响应和多提供商支持
- **react-drawio**:用于图表表示和操作
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 多提供商支持
- AWS Bedrock默认
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。
## 快速开始
### 使用Docker运行推荐
如果您只想在本地运行最好的方式是使用Docker。
首先如果您还没有安装Docker请先安装[获取Docker](https://docs.docker.com/get-docker/)
然后运行:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
请根据您首选的AI提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
### 安装
1. 克隆仓库:
```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io
```
2. 安装依赖:
```bash
npm install
# 或
yarn install
```
3. 配置您的AI提供商
在根目录创建 `.env.local` 文件:
```bash
cp env.example .env.local
```
编辑 `.env.local` 并配置您选择的提供商:
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
-`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
4. 运行开发服务器:
```bash
npm run dev
```
5. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
## 部署
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
或者您可以通过此按钮部署:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
## 项目结构
```
app/ # Next.js App Router
api/chat/ # 带AI工具的聊天API端点
page.tsx # 带DrawIO嵌入的主页面
components/ # React组件
chat-panel.tsx # 带图表控制的聊天界面
chat-input.tsx # 带文件上传的用户输入组件
history-dialog.tsx # 图表版本历史查看器
ui/ # UI组件按钮、卡片等
contexts/ # React上下文提供者
diagram-context.tsx # 全局图表状态管理
lib/ # 工具函数和辅助程序
ai-providers.ts # 多提供商AI配置
utils.ts # XML处理和转换工具
public/ # 静态资源包括示例图片
```
## 待办事项
- [x] 允许LLM修改XML而不是每次从头生成
- [x] 提高形状流式更新的流畅度
- [x] 添加多AI提供商支持OpenAI, Anthropic, Google, Azure, Ollama
- [x] 解决超过60秒的会话生成失败的bug
- [ ] 在UI上添加API配置
## 支持与联系
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
如需支持或咨询请在GitHub仓库上提交issue或联系维护者
- 邮箱me[at]jiang.jp
## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
---

View File

@@ -1,216 +0,0 @@
# Next AI Draw.io
<div align="center">
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
[English](./README.md) | [中文](./README_CN.md) | 日本語
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Next.js](https://img.shields.io/badge/Next.js-15.x-black)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[🚀 ライブデモ](https://next-ai-drawio.jiang.jp/)
</div>
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 機能
- **LLM搭載のダイアグラム作成**大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
- **画像ベースのダイアグラム複製**既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **AWSアーキテクチャダイアグラムサポート**AWSアーキテクチャダイアグラムの生成を専門的にサポート
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## **例**
以下はいくつかのプロンプト例と生成されたダイアグラムです:
<div align="center">
<table width="100%">
<tr>
<td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
<img src="./public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCPアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="./public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>AWSアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="./public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Azureアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="./public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫のスケッチ</strong><br />
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
<img src="./public/cat_demo.svg" alt="猫の絵" width="240" />
</td>
</tr>
</table>
</div>
## 仕組み
本アプリケーションは以下の技術を使用しています:
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**`ai` + `@ai-sdk/*`ストリーミングAIレスポンスとマルチプロバイダーサポート
- **react-drawio**:ダイアグラムの表現と操作
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
## マルチプロバイダーサポート
- AWS Bedrockデフォルト
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
## はじめに
### Dockerで実行推奨
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
まず、Dockerをインストールしていない場合はインストールしてください[Dockerを入手](https://docs.docker.com/get-docker/)
次に実行:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。
環境変数はお好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
### インストール
1. リポジトリをクローン:
```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io
```
2. 依存関係をインストール:
```bash
npm install
# または
yarn install
```
3. AIプロバイダーを設定
ルートディレクトリに`.env.local`ファイルを作成:
```bash
cp env.example .env.local
```
`.env.local`を編集して選択したプロバイダーを設定:
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
- `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
4. 開発サーバーを起動:
```bash
npm run dev
```
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
## デプロイ
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
または、このボタンでデプロイできます:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
## プロジェクト構造
```
app/ # Next.js App Router
api/chat/ # AIツール付きチャットAPIエンドポイント
page.tsx # DrawIO埋め込み付きメインページ
components/ # Reactコンポーネント
chat-panel.tsx # ダイアグラム制御付きチャットインターフェース
chat-input.tsx # ファイルアップロード付きユーザー入力コンポーネント
history-dialog.tsx # ダイアグラムバージョン履歴ビューア
ui/ # UIコンポーネントボタン、カードなど
contexts/ # Reactコンテキストプロバイダー
diagram-context.tsx # グローバルダイアグラム状態管理
lib/ # ユーティリティ関数とヘルパー
ai-providers.ts # マルチプロバイダーAI設定
utils.ts # XML処理と変換ユーティリティ
public/ # サンプル画像を含む静的アセット
```
## TODO
- [x] LLMが毎回ゼロから生成する代わりにXMLを修正できるようにする
- [x] シェイプストリーミング更新の滑らかさを改善
- [x] 複数のAIプロバイダーサポートを追加OpenAI, Anthropic, Google, Azure, Ollama
- [x] 60秒以上のセッションで生成が失敗するバグを解決
- [ ] UIにAPI設定を追加
## サポート&お問い合わせ
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください
- メールme[at]jiang.jp
## スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
---

View File

@@ -1,22 +0,0 @@
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
build:
commands:
# Write env vars to .env.production for Next.js SSR runtime
- env | grep -e AI_MODEL >> .env.production
- env | grep -e AI_PROVIDER >> .env.production
- env | grep -e OPENAI_API_KEY >> .env.production
- env | grep -e NEXT_PUBLIC_ >> .env.production
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- .npm/**/*

View File

@@ -1,334 +0,0 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = {
title: "关于 - Next AI Draw.io",
description:
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
}
export default function AboutCN() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label="在GitHub上查看"
>
<FaGithub className="w-5 h-5" />
</a>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="prose prose-lg max-w-none">
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 -
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800">
Claude Opus 4.5
Claude Haiku 4.5
</p>
</div>
<p className="text-gray-700">
AI功能的Next.js网页应用draw.io图表无缝结合AI辅助可视化来创建
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>LLM驱动的图表创建</strong>
draw.io图表
</li>
<li>
<strong></strong>
AI自动复制和增强
</li>
<li>
<strong></strong>
AI编辑前的图表版本
</li>
<li>
<strong></strong>
AI实时对话来完善您的图表
</li>
<li>
<strong>AWS架构图支持</strong>
AWS架构图
</li>
<li>
<strong></strong>
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8">
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformer连接器
</h3>
<p className="text-gray-600 mb-4">
<strong></strong>
<strong></strong>Transformer架构图
</p>
<Image
src="/animated_connectors.svg"
alt="带动画连接器的Transformer架构"
width={480}
height={360}
className="mx-auto"
/>
</div>
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong> 使
<strong>GCP图标</strong>
GCP架构图
</p>
<Image
src="/gcp_demo.svg"
alt="GCP架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong> 使
<strong>AWS图标</strong>
AWS架构图
</p>
<Image
src="/aws_demo.svg"
alt="AWS架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong> 使
<strong>Azure图标</strong>
Azure架构图
</p>
<Image
src="/azure_demo.svg"
alt="Azure架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "}
</p>
<Image
src="/cat_demo.svg"
alt="猫咪绘图"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-4">使</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>
</li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
用于流式AI响应和多提供商支持
</li>
<li>
<strong>react-drawio</strong>:用于图表表示和操作
</li>
</ul>
<p className="text-gray-700 mt-4">
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
</p>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li>
<li>
OpenAI / OpenAI兼容API{" "}
<code>OPENAI_BASE_URL</code>
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "}
AWS标志的draw.io图表上进行训练AWS架构图
</p>
{/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>{" "}
线
</p>
<p className="text-gray-700 mt-2">
{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub仓库
</a>{" "}
issue或联系me[at]jiang.jp
</p>
{/* CTA */}
<div className="mt-12 text-center">
<Link
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
</Link>
</div>
</article>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
Next AI Draw.io - AI驱动的图表生成器
</p>
</div>
</footer>
</div>
)
}

View File

@@ -1,353 +0,0 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = {
title: "概要 - Next AI Draw.io",
description:
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
keywords: [
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
}
export default function AboutJA() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label="GitHubで見る"
>
<FaGithub className="w-5 h-5" />
</a>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="prose prose-lg max-w-none">
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium">
AI搭載のダイアグラム作成ツール -
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800">
Claude
Opus 4.5
Claude Haiku 4.5
</p>
</div>
<p className="text-gray-700">
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションですAI支援の可視化により
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>LLM搭載のダイアグラム作成</strong>
draw.ioダイアグラムを作成
</li>
<li>
<strong></strong>
AIが自動的に複製
</li>
<li>
<strong></strong>
AI編集前のダイアグラムの以前のバージョンを表示
</li>
<li>
<strong>
</strong>
AIとリアルタイムでコミュニケーションしてダイアグラムを改善
</li>
<li>
<strong>
AWSアーキテクチャダイアグラムサポート
</strong>
AWSアーキテクチャダイアグラムの生成を専門的にサポート
</li>
<li>
<strong></strong>
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8">
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformerコネクタ
</h3>
<p className="text-gray-600 mb-4">
<strong></strong>{" "}
<strong></strong>
Transformerアーキテクチャ図を作成してください
</p>
<Image
src="/animated_connectors.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
width={480}
height={360}
className="mx-auto"
/>
</div>
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "}
<strong>GCPアイコン</strong>
使GCPアーキテクチャ図を生成してください
</p>
<Image
src="/gcp_demo.svg"
alt="GCPアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWSアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "}
<strong>AWSアイコン</strong>
使AWSアーキテクチャ図を生成してください
</p>
<Image
src="/aws_demo.svg"
alt="AWSアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azureアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "}
<strong>Azureアイコン</strong>
使Azureアーキテクチャ図を生成してください
</p>
<Image
src="/azure_demo.svg"
alt="Azureアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "}
</p>
<Image
src="/cat_demo.svg"
alt="猫の絵"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-4">
使
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>
</li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>react-drawio</strong>
:ダイアグラムの表現と操作
</li>
</ul>
<p className="text-gray-700 mt-4">
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
</p>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li>
<li>
OpenAI / OpenAI互換API<code>OPENAI_BASE_URL</code>
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>
AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です
</p>
{/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>{" "}
</p>
<p className="text-gray-700 mt-2">
{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHubリポジトリ
</a>{" "}
issueを開くかme[at]jiang.jp
</p>
{/* CTA */}
<div className="mt-12 text-center">
<Link
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
</Link>
</div>
</article>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
Next AI Draw.io -
AI搭載ダイアグラムジェネレーター
</p>
</div>
</footer>
</div>
)
}

View File

@@ -1,46 +1,28 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import type { Metadata } from "next";
import Link from "next/link";
import { FaGithub } from "react-icons/fa";
export const metadata: Metadata = {
title: "About - Next AI Draw.io",
description:
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
keywords: [
"AI diagram",
"draw.io",
"AWS architecture",
"GCP diagram",
"Azure diagram",
"LLM",
],
}
title: "About - AI-Powered Diagram Generator | Next AI Draw.io",
description: "Learn about Next AI Draw.io, a free AI-powered diagram creation tool. Create AWS architecture diagrams, flowcharts, and UML diagrams using Claude Sonnet and GPT-4. No login required.",
keywords: ["about AI diagram generator", "diagram tool features", "how to create diagrams", "AI drawing tool capabilities", "draw.io integration"],
};
export default function About() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
Editor
</Link>
<Link
href="/about"
className="text-blue-600 font-semibold"
>
<Link href="/about" className="text-blue-600 font-semibold">
About
</Link>
<a
@@ -58,313 +40,297 @@ export default function About() {
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="prose prose-lg max-w-none">
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article>
{/* Hero Section */}
<header className="mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
AI-Powered Diagram Generator | Create Professional Diagrams Instantly
</h1>
<p className="text-xl text-gray-600 font-medium">
AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize
<p className="text-xl text-gray-600">
Free, open-source diagram creation tool powered by AI. No login required, no installation needed.
</p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div>
</header>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800">
This app is designed to run on Claude Opus 4.5 for
best performance. However, due to
higher-than-expected traffic, running the top-tier
model has become cost-prohibitive. To avoid service
interruptions and manage costs, I have switched the
backend to Claude Haiku 4.5.
</p>
</div>
<p className="text-gray-700">
A Next.js web application that integrates AI
capabilities with draw.io diagrams. Create, modify, and
enhance diagrams through natural language commands and
AI-assisted visualization.
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Features
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>LLM-Powered Diagram Creation</strong>:
Leverage Large Language Models to create and
manipulate draw.io diagrams directly through natural
language commands
</li>
<li>
<strong>Image-Based Diagram Replication</strong>:
Upload existing diagrams or images and have the AI
replicate and enhance them automatically
</li>
<li>
<strong>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</li>
<li>
<strong>Interactive Chat Interface</strong>:
Communicate with AI to refine your diagrams in
real-time
</li>
<li>
<strong>AWS Architecture Diagram Support</strong>:
Specialized support for generating AWS architecture
diagrams
</li>
<li>
<strong>Animated Connectors</strong>: Create dynamic
and animated connectors between diagram elements for
better visualization
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Examples
</h2>
<p className="text-gray-700 mb-6">
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8">
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated Transformer Connectors
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
{/* Introduction */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">What is Next AI Draw.io?</h2>
<div className="prose prose-lg max-w-none text-gray-700">
<p className="mb-4">
<strong>Next AI Draw.io</strong> is a free, AI-powered diagram creation tool that integrates seamlessly with draw.io.
Generate AWS architecture diagrams, flowcharts, UML diagrams, and technical documentation diagrams using natural language
prompts. No login required, no installation neededstart creating professional diagrams instantly in your browser.
</p>
<p className="mb-4">
Our intelligent diagram generator uses advanced AI models including <strong>Claude Sonnet</strong> and <strong>GPT-4</strong> to
understand your requirements and automatically create properly structured diagrams with appropriate symbols, layouts, and connections.
Simply describe what you need, upload reference images, or ask the AI to modify existing diagrams with our targeted XML editing feature.
</p>
<p>
Whether you're a software architect designing system infrastructure, a developer documenting APIs, a business analyst creating
process flows, or a student working on technical assignments, Next AI Draw.io makes diagram creation fast, accurate, and effortless.
</p>
<Image
src="/animated_connectors.svg"
alt="Transformer Architecture with Animated Connectors"
width={480}
height={360}
className="mx-auto"
/>
</div>
</section>
{/* Cloud Architecture Grid */}
{/* Key Features */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Key Features</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP Architecture Diagram
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
AI-Powered Diagram Creation
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a GCP
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
<p className="text-gray-700">
Generate diagrams from natural language descriptions using Claude Sonnet or GPT-4.
Describe your diagram in plain English, and watch the AI create it with proper symbols,
layouts, and connections automatically.
</p>
<Image
src="/gcp_demo.svg"
alt="GCP Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS Architecture Diagram
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
AWS Architecture Diagrams
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an AWS
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
<p className="text-gray-700">
Create professional cloud infrastructure diagrams with AWS-style icons and layouts.
Perfect for designing EC2 instances, Lambda functions, S3 buckets, RDS databases, VPCs,
and complete AWS solution architectures.
</p>
<Image
src="/aws_demo.svg"
alt="AWS Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure Architecture Diagram
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
Image-Based Diagram Replication
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an Azure
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
<p className="text-gray-700">
Upload existing diagrams or sketches, and the AI will automatically recreate them in draw.io format.
Modify uploaded images by describing the changes you want—the AI handles the rest.
</p>
<Image
src="/azure_demo.svg"
alt="Azure Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Cat Sketch
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
Diagram History & Version Control
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Draw a cute cat for
me.
<p className="text-gray-700">
Access previous versions of your diagrams and restore any version from your session history.
Never lose work—every AI modification is saved and can be undone with a single click.
</p>
</div>
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
Targeted XML Editing
</h3>
<p className="text-gray-700">
Precise diagram modifications using intelligent XML manipulation. Unlike full diagram regeneration,
targeted edits preserve your existing layout while making specific changes, ensuring consistent
and predictable results.
</p>
</div>
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
<span className="text-blue-600 mr-2">✓</span>
Multi-Provider AI Support
</h3>
<p className="text-gray-700">
Choose between Claude Sonnet, GPT-4, and other leading AI models for optimal results.
Each model has unique strengths—select the one that best fits your diagram complexity and style.
</p>
<Image
src="/cat_demo.svg"
alt="Cat Drawing"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
</section>
{/* Use Cases */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Popular Use Cases</h2>
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-blue-50 p-6 rounded-lg border border-blue-200">
<h3 className="text-xl font-semibold text-gray-900 mb-3">AWS Cloud Architecture</h3>
<p className="text-gray-700 mb-4">
Design scalable cloud infrastructure with EC2 instances, Lambda functions, S3 storage,
RDS databases, and VPC networking. Perfect for solution architects, cloud engineers,
and DevOps teams planning AWS deployments.
</p>
<p className="text-sm text-gray-600 italic">
Example: "Create an AWS diagram with an Application Load Balancer, two EC2 instances
in different availability zones, an RDS database, and an S3 bucket for static assets."
</p>
</div>
<div className="bg-green-50 p-6 rounded-lg border border-green-200">
<h3 className="text-xl font-semibold text-gray-900 mb-3">Flowcharts & Process Diagrams</h3>
<p className="text-gray-700 mb-4">
Create business process flows, decision trees, workflow diagrams, and algorithm flowcharts
for documentation, presentations, and process optimization. Ideal for business analysts,
project managers, and operations teams.
</p>
<p className="text-sm text-gray-600 italic">
Example: "Draw a flowchart for user authentication: check if user exists, verify password,
generate JWT token on success, show error message on failure."
</p>
</div>
<div className="bg-purple-50 p-6 rounded-lg border border-purple-200">
<h3 className="text-xl font-semibold text-gray-900 mb-3">System Design & UML Diagrams</h3>
<p className="text-gray-700 mb-4">
Generate system architecture diagrams, class diagrams, sequence diagrams, and
entity-relationship diagrams for software projects. Essential for software engineers,
system designers, and technical documentation.
</p>
<p className="text-sm text-gray-600 italic">
Example: "Create a class diagram for an e-commerce system with User, Product, Order,
and Payment classes showing their relationships and key methods."
</p>
</div>
</div>
</section>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
How It Works
</h2>
<p className="text-gray-700 mb-4">
The application uses the following technologies:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>: For the frontend framework
and routing
</li>
<li>
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
<code>@ai-sdk/*</code>): For streaming AI responses
and multi-provider support
</li>
<li>
<strong>react-drawio</strong>: For diagram
representation and manipulation
</li>
</ul>
<p className="text-gray-700 mt-4">
Diagrams are represented as XML that can be rendered in
draw.io. The AI processes your commands and generates or
modifies this XML accordingly.
</p>
<section className="mb-12 bg-white p-8 rounded-lg border border-gray-200">
<h2 className="text-2xl font-semibold text-gray-900 mb-6">How to Use Next AI Draw.io</h2>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
1
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Open the Editor</h3>
<p className="text-gray-700">
Navigate to the main page and you'll see the draw.io editor with an AI chat panel on the right.
No account creation or login requiredstart immediately.
</p>
</div>
</div>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Multi-Provider Support
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock (default)</li>
<li>
OpenAI / OpenAI-compatible APIs (via{" "}
<code>OPENAI_BASE_URL</code>)
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
</ul>
<p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on
draw.io diagrams with AWS logos, so if you want to
create AWS architecture diagrams, this is the best
choice.
</p>
<div className="flex items-start">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
2
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Describe Your Diagram</h3>
<p className="text-gray-700">
Type your diagram request in natural language. Be as detailed or as general as you like.
You can also upload reference images for the AI to analyze and replicate.
</p>
</div>
</div>
{/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
Support &amp; Contact
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
If you find this project useful, please consider{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
sponsoring
</a>{" "}
to help host the live demo site!
</p>
<p className="text-gray-700 mt-2">
For support or inquiries, please open an issue on the{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub repository
</a>{" "}
or contact: me[at]jiang.jp
</p>
<div className="flex items-start">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
3
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Generates Your Diagram</h3>
<p className="text-gray-700">
The AI processes your request and automatically creates your diagram in seconds.
Watch as it appears in the editor with proper symbols, layouts, and connections.
</p>
</div>
</div>
{/* CTA */}
<div className="mt-12 text-center">
<div className="flex items-start">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
4
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Refine and Export</h3>
<p className="text-gray-700">
Request modifications using the chat, manually edit in draw.io, or export to PNG, SVG,
or XML format. Access diagram history to restore previous versions anytime.
</p>
</div>
</div>
</div>
</section>
{/* Benefits */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Why Choose Next AI Draw.io?</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="flex items-start">
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3"></div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Save Time</h3>
<p className="text-gray-700">
Create complex diagrams in seconds instead of hours. No more dragging, aligning,
or searching for the right symbolsAI handles it all.
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🎯</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Precision Editing</h3>
<p className="text-gray-700">
Targeted XML editing ensures changes are precise and predictable, unlike tools
that regenerate entire diagrams and lose your layout.
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🆓</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Completely Free</h3>
<p className="text-gray-700">
No subscriptions, no usage limits, no hidden costs. Open-source and free forever.
Use it for personal projects, work, or education.
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🔒</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Privacy First</h3>
<p className="text-gray-700">
No account required means your diagrams stay private. Work on sensitive
architecture designs without worrying about data storage or privacy policies.
</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-blue-600 text-white p-8 rounded-lg text-center">
<h2 className="text-3xl font-bold mb-4">Ready to Create Your First AI Diagram?</h2>
<p className="text-xl mb-6">
Start generating professional diagrams in seconds. No signup required.
</p>
<Link
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
className="inline-block bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
>
Open Editor
</Link>
</div>
</section>
</article>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
Next AI Draw.io - Open Source AI-Powered Diagram
Generator
</p>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600 text-sm">
<p className="mb-2">
Next AI Draw.io - Free AI-Powered Diagram Generator
</p>
<p>
Perfect for developers, architects, students, and business analysts.
Open source. No login required.
</p>
</div>
</div>
</footer>
</div>
)
);
}

View File

@@ -1,390 +1,195 @@
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
streamText,
} from "ai"
import { z } from "zod"
import { getAIModel } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
getTelemetryConfig,
setTraceInput,
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
import { streamText, convertToModelMessages } from 'ai';
import { getAIModel } from '@/lib/ai-providers';
import { z } from "zod";
export const maxDuration = 60
export const maxDuration = 60;
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
export async function POST(req: Request) {
try {
const { messages, xml } = await req.json();
// 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") || []
const systemMessage = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded.
Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
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 }
You utilize the following tools:
---Tool1---
tool name: display_diagram
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
parameters: {
xml: string
}
// Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
---Tool2---
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
edits: Array<{search: string, replace: string}>
}
---End of tools---
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`
IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "start" })
writer.write({
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
})
writer.write({
type: "tool-input-delta",
toolCallId,
inputTextDelta: xml,
})
writer.write({
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml },
})
writer.write({ type: "finish" })
},
})
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
- Adapt artistic concepts into abstract diagram representations using available shapes
- Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components
return createUIMessageStreamResponse({ stream })
}
Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Use compact, efficient layouts that fit the entire diagram in one view
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
// Inner handler function
async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length > 0) {
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
return Response.json(
{
error: "Invalid or missing access code. Please configure it in Settings.",
},
{ status: 401 },
)
}
}
Note that:
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
const { messages, xml, sessionId } = await req.json()
When using edit_diagram tool:
- Keep edits minimal - only include the specific line being changed plus 1-2 context lines
- Example GOOD edit: {"search": " <mxCell id=\"2\" value=\"Old Text\">", "replace": " <mxCell id=\"2\" value=\"New Text\">"}
- Example BAD edit: Including 10+ unchanged lines just to change one attribute
- For multiple changes, use separate edits: [{"search": "line1", "replace": "new1"}, {"search": "line2", "replace": "new2"}]
- RETRY POLICY: If edit_diagram fails because the search pattern cannot be found:
* You may retry edit_diagram up to 3 times with adjusted search patterns
* After 3 failed attempts, you MUST fall back to using display_diagram to regenerate the entire diagram
* The error message will indicate how many retries remain
`;
// Get user IP for Langfuse tracking
const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
// Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId =
sessionId && typeof sessionId === "string" && sessionId.length <= 200
? sessionId
: undefined
// Extract user input text for Langfuse trace
const currentMessage = messages[messages.length - 1]
const userInputText =
currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user
setTraceInput({
input: userInputText,
sessionId: validSessionId,
userId: userId,
})
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 })
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
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]
const lastMessage = messages[messages.length - 1];
// Extract text from the last message parts
const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
// Extract file parts (images) from the last message
const fileParts =
lastMessage.parts?.filter((part: any) => part.type === "file") || []
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
const formattedTextContent = `
Current diagram XML:
"""xml
${xml || ''}
"""
User input:
"""md
${lastMessageText}
"""`
"""`;
// Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages)
const modelMessages = convertToModelMessages(messages);
let enhancedMessages = [...modelMessages];
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = modelMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
// Update the last message with user input only (XML moved to separate cached system message)
// Update the last message with formatted content if it's a user message
if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
if (lastModelMessage.role === "user") {
// Build content array with user input text and file parts
const contentParts: any[] = [
{ type: "text", text: formattedUserInput },
]
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1];
if (lastModelMessage.role === 'user') {
// Build content array with text and file parts
const contentParts: any[] = [
{ type: 'text', text: formattedTextContent }
];
// Add image parts back
for (const filePart of fileParts) {
contentParts.push({
type: "image",
image: filePart.url,
mimeType: filePart.mediaType,
})
}
enhancedMessages = [
...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts },
]
// Add image parts back
for (const filePart of fileParts) {
contentParts.push({
type: 'image',
image: filePart.url,
mimeType: filePart.mediaType
});
}
enhancedMessages = [
...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts }
];
}
}
// Add cache point to the last assistant message in conversation history
// This caches the entire conversation prefix for subsequent requests
// Strategy: system (cached) + history with last assistant (cached) + new user message
if (enhancedMessages.length >= 2) {
// Find the last assistant message (should be second-to-last, before current user message)
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = {
...enhancedMessages[i],
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}
break // Only cache the last assistant message
}
}
}
console.log("Enhanced messages:", enhancedMessages);
// System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// This allows: if only user message changes, both system caches are reused
// if XML changes, instruction cache is still reused
const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change)
{
role: "system" as const,
content: systemMessage,
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
},
// Cache breakpoint 2: Current diagram XML context
{
role: "system" as const,
content: `Current diagram XML:\n"""xml\n${xml || ""}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
},
]
const allMessages = [...systemMessages, ...enhancedMessages]
// Get AI model from environment configuration
const { model, providerOptions } = getAIModel();
const result = streamText({
model,
messages: allMessages,
...(providerOptions && { providerOptions }),
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({
sessionId: validSessionId,
userId,
}),
}),
onFinish: ({ text, usage, providerMetadata }) => {
console.log(
"[Cache] Full providerMetadata:",
JSON.stringify(providerMetadata, null, 2),
)
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
setTraceOutput(text, {
promptTokens: usage?.inputTokens,
completionTokens: usage?.outputTokens,
})
model,
system: systemMessage,
messages: enhancedMessages,
...(providerOptions && { providerOptions }),
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. You only need to pass the nodes inside the <root> tag (including the <root> tag itself) in the XML string.
For example:
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxGeometry x="20" y="20" width="100" height="100" as="geometry"/>
<mxCell id="2" value="Hello, World!" style="shape=rectangle" parent="1">
<mxGeometry x="20" y="20" width="100" height="100" as="geometry"/>
</mxCell>
</root>
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
- If you are asked to generate animated connectors, make sure to include "flowAnimation=1" in the style of the connector elements.
`,
inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io")
})
},
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested
2. Every mxCell needs a unique id
3. Every mxCell (except id="0") needs a valid parent attribute
4. Edge source/target must reference existing cell IDs
5. Escape special chars in values: &lt; &gt; &amp; &quot;
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
Example with swimlanes and edges (note: all mxCells are siblings):
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style.
`,
inputSchema: z.object({
xml: z
.string()
.describe("XML string to be displayed on draw.io"),
}),
},
edit_diagram: {
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
edit_diagram: {
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element`,
inputSchema: z.object({
edits: z
.array(
z.object({
search: z
.string()
.describe(
"EXACT lines copied from current XML (preserve attribute order!)",
),
replace: z
.string()
.describe("Replacement lines"),
}),
)
.describe(
"Array of search/replace pairs to apply sequentially",
),
}),
},
inputSchema: z.object({
edits: z.array(z.object({
search: z.string().describe("Exact lines to search for (including whitespace and indentation)"),
replace: z.string().describe("Replacement lines")
})).describe("Array of search/replace pairs to apply sequentially")
})
},
temperature: 0,
})
},
temperature: 0,
});
return result.toUIMessageStreamResponse()
}
// Error handler function to provide detailed error messages
function errorHandler(error: unknown) {
if (error == null) {
return 'unknown error';
}
// Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> {
try {
return await handleChatRequest(req)
} catch (error) {
console.error("Error in chat route:", error)
return Response.json(
{ error: "Internal server error" },
{ status: 500 },
)
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return JSON.stringify(error);
}
}
// Wrap with Langfuse observe (if configured)
const observedHandler = wrapWithObserve(safeHandler)
export async function POST(req: Request) {
return observedHandler(req)
return result.toUIMessageStreamResponse({
onError: errorHandler,
});
} catch (error) {
console.error('Error in chat route:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -211,7 +211,6 @@ Draw.io files contain two special cells that are always present:
4. Define parent relationships correctly
5. Use `mxGeometry` elements to position shapes
6. For connectors, specify `source` and `target` attributes
7. **CRITICAL: All mxCell elements must be DIRECT children of `<root>`. NEVER nest mxCell inside another mxCell.**
## Common Patterns
@@ -235,33 +234,12 @@ To group elements, create a parent cell and set other cells' `parent` attribute
### Swimlanes
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
Swimlanes use the `swimlane` shape style:
```xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Swimlane 1 -->
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Swimlane 2 -->
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Step inside lane1 (parent="lane1") -->
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Step inside lane2 (parent="lane2") -->
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
<mxCell id="20" value="Swimlane 1" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="200" width="140" height="120" as="geometry" />
</mxCell>
```
### Tables

View File

@@ -1,12 +0,0 @@
import { NextResponse } from "next/server"
export async function GET() {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
return NextResponse.json({
accessCodeRequired: accessCodes.length > 0,
})
}

View File

@@ -1,112 +0,0 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse"
const feedbackSchema = z.object({
messageId: z.string().min(1).max(200),
feedback: z.enum(["good", "bad"]),
sessionId: z.string().min(1).max(200).optional(),
})
export async function POST(req: Request) {
const langfuse = getLangfuseClient()
if (!langfuse) {
return Response.json({ success: true, logged: false })
}
// Validate input
let data
try {
data = feedbackSchema.parse(await req.json())
} catch {
return Response.json(
{ success: false, error: "Invalid input" },
{ status: 400 },
)
}
const { messageId, feedback, sessionId } = data
// Get user IP for tracking
const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
try {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID()
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "trace-create",
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: "user-feedback",
sessionId,
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
timestamp,
},
},
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
}
return Response.json({ success: true, logged: true })
} catch (error) {
console.error("Langfuse feedback error:", error)
return Response.json(
{ success: false, error: "Failed to log feedback" },
{ status: 500 },
)
}
}

View File

@@ -1,71 +0,0 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(),
})
export async function POST(req: Request) {
const langfuse = getLangfuseClient()
if (!langfuse) {
return Response.json({ success: true, logged: false })
}
// Validate input
let data
try {
data = saveSchema.parse(await req.json())
} catch {
return Response.json(
{ success: false, error: "Invalid input" },
{ status: 400 },
)
}
const { filename, format, sessionId } = data
try {
const timestamp = new Date().toISOString()
// Find the most recent chat trace for this session to attach the save flag
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (latestTrace) {
// Add a score to the existing trace to flag that user saved
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
},
},
],
})
}
// If no trace found, skip logging (user hasn't chatted yet)
return Response.json({ success: true, logged: !!latestTrace })
} catch (error) {
console.error("Langfuse save error:", error)
return Response.json(
{ success: false, error: "Failed to log save" },
{ status: 500 },
)
}
}

View File

@@ -1,259 +1,123 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.75rem;
/* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */
--muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Coral destructive */
--destructive: oklch(0.6 0.2 25);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260);
/* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.5 0.15 200);
/* Sidebar */
--sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);
--ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.7 0.16 265);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}
/* Fix for Radix ScrollArea viewport horizontal overflow */
[data-slot="scroll-area-viewport"] > div {
display: block !important;
width: 100% !important;
}
/* Custom scrollbar */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 260) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.85 0.01 260);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260);
}
}
/* Smooth page transitions */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
/* Message bubble animations */
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-message-in {
animation: messageIn 0.25s ease-out forwards;
}
/* Subtle floating shadow for cards */
.shadow-soft {
box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04);
}
.shadow-soft-lg {
box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06);
}
/* Gradient text utility */
.text-gradient-primary {
background: linear-gradient(
135deg,
oklch(0.55 0.18 265),
oklch(0.6 0.2 290)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

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

View File

@@ -1,136 +1,76 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
"use client";
import React, { useState, useEffect } from "react";
import { DrawIoEmbed } from "react-drawio";
import ChatPanel from "@/components/chat-panel";
import { useDiagram } from "@/contexts/diagram-context";
export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved
}
return "min"
})
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const { drawioRef, handleDiagramExport } = useDiagram();
const [isMobile, setIsMobile] = useState(false);
const [isChatVisible, setIsChatVisible] = useState(true);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
setIsMobile(window.innerWidth < 768);
};
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
// Check on mount
checkMobile();
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
if (panel.isCollapsed()) {
panel.expand()
setIsChatVisible(true)
} else {
panel.collapse()
setIsChatVisible(false)
}
}
}
// Add event listener for resize
window.addEventListener("resize", checkMobile);
// Cleanup
return () => window.removeEventListener("resize", checkMobile);
}, []);
// Add keyboard shortcut for toggling chat panel (Ctrl+B)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault()
toggleChatPanel()
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
event.preventDefault();
setIsChatVisible((prev) => !prev);
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
window.addEventListener('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 () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
{/* Draw.io Canvas */}
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
<div
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
<DrawIoEmbed
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
</div>
<div className="flex h-screen bg-gray-100 relative">
{/* Mobile warning overlay - keeps components mounted */}
{isMobile && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-gray-100">
<div className="text-center p-8">
<h1 className="text-2xl font-semibold text-gray-800">
Please open this application on a desktop or laptop
</h1>
</div>
</ResizablePanel>
</div>
)}
<ResizableHandle withHandle />
{/* Chat Panel */}
<ResizablePanel
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
minSize={isMobile ? 20 : 15}
maxSize={isMobile ? 80 : 50}
collapsible={!isMobile}
collapsedSize={isMobile ? 0 : 3}
onCollapse={() => setIsChatVisible(false)}
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={() => {
const newTheme =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newTheme)
setDrawioUi(newTheme)
}}
isMobile={isMobile}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<div className={`${isChatVisible ? 'w-2/3' : 'w-full'} p-1 h-full relative transition-all duration-300 ease-in-out`}>
<DrawIoEmbed
ref={drawioRef}
onExport={handleDiagramExport}
urlParameters={{
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
</div>
<div className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full p-1 transition-all duration-300 ease-in-out`}>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
/>
</div>
</div>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,133 +1,82 @@
"use client"
import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
}
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
return (
<button
onClick={onClick}
className="group w-full text-left p-4 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 group-hover:bg-primary/15 transition-colors">
{icon}
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
)
}
export default function ExamplePanel({
setInput,
setFiles,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
}) {
// New handler for the "Replicate this flowchart" button
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
setInput("Replicate this flowchart.");
try {
const response = await fetch("/example.png")
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
// Fetch the example image
const response = await fetch("/example.png");
const blob = await response.blob();
const file = new File([blob], "example.png", { type: "image/png" });
// Set the file to the files state
setFiles([file]);
} catch (error) {
console.error("Error loading example image:", error)
console.error("Error loading example image:", error);
}
}
};
// Handler for the "Replicate this in aws style" button
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style")
setInput("Replicate this in aws style");
try {
const response = await fetch("/architecture.png")
const blob = await response.blob()
// Fetch the architecture image
const response = await fetch("/architecture.png");
const blob = await response.blob();
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
});
// Set the file to the files state
setFiles([file]);
} catch (error) {
console.error("Error loading architecture image:", error)
console.error("Error loading architecture image:", error);
}
}
};
return (
<div className="py-6 px-2 animate-fade-in">
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
Create diagrams with AI
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to
replicate
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
Quick Examples
</p>
<div className="grid gap-2">
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title="AWS Architecture"
description="Create a cloud architecture diagram with AWS icons"
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title="Replicate Flowchart"
description="Upload and replicate an existing flowchart"
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing"
description="Draw something fun and creative"
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
Examples are cached for instant response
</p>
<div className="px-4 py-2 border-t border-b border-gray-100">
<p className="text-sm text-gray-500 mb-2">
{" "}
Start a conversation to generate or modify diagrams.
</p>
<p className="text-sm text-gray-500 mb-2">
{" "}
You can also upload images to use as references.
</p>
<p className="text-sm text-gray-500 mb-2">Try these examples:</p>
<div className="flex flex-wrap gap-5">
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
>
Draw diagram with Animated Connectors
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={handleReplicateArchitecture}
>
Create AWS architecture
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={handleReplicateFlowchart}
>
Replicate flowchart
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={() => setInput("Draw a cat for me")}
>
Draw a cat
</button>
</div>
</div>
)
);
}

View File

@@ -1,130 +1,31 @@
"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 {
Download,
History,
Image as ImageIcon,
LayoutGrid,
Loader2,
PenTool,
Send,
Trash2,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { useDiagram } from "@/contexts/diagram-context"
import { FilePreviewList } from "./file-preview-list"
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[]
errors: string[]
}
function validateFiles(
newFiles: File[],
existingCount: number,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`)
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`)
break
}
if (file.size > MAX_FILE_SIZE) {
errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
function showValidationErrors(errors: string[]) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{errors.length} files rejected:
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
)}
</ul>
</div>,
)
}
}
RotateCcw,
Image as ImageIcon,
History,
} from "lucide-react";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context";
import { HistoryDialog } from "@/components/history-dialog";
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void
files?: File[]
onFileChange?: (files: File[]) => void
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void
input: string;
status: "submitted" | "streaming" | "ready" | "error";
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onClearChat: () => void;
files?: File[];
onFileChange?: (files: File[]) => void;
showHistory?: boolean;
onToggleHistory?: (show: boolean) => void;
}
export function ChatInput({
@@ -137,344 +38,243 @@ export function ChatInput({
onFileChange = () => {},
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false)
const { diagramHistory } = useDiagram();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false);
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
// Debug: Log status changes
const isDisabled = status === "streaming" || status === "submitted";
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
// Auto-resize textarea based on content
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
// Handle keyboard shortcuts and paste events
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
const form = e.currentTarget.closest("form")
e.preventDefault();
const form = e.currentTarget.closest("form");
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
form.requestSubmit();
}
}
}
};
// Handle clipboard paste
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
if (isDisabled) return;
const items = e.clipboardData.items
const items = e.clipboardData.items;
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
item.type.startsWith("image/")
);
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const imageFiles = await Promise.all(
imageItems.map(async (item) => {
const file = item.getAsFile();
if (!file) return null;
// Create a new file with a unique name
return new File(
[file],
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
{
type: file.type,
}
);
})
);
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
)
showValidationErrors(errors)
const validFiles = imageFiles.filter(
(file): file is File => file !== null
);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
onFileChange([...files, ...validFiles]);
}
}
}
};
// Handle file changes
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const newFiles = Array.from(e.target.files || []);
onFileChange([...files, ...newFiles]);
};
// Remove individual file
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
onFileChange(files.filter((file) => file !== fileToRemove));
if (fileInputRef.current) {
fileInputRef.current.value = ""
fileInputRef.current.value = "";
}
}
};
// Trigger file input click
const triggerFileInput = () => {
fileInputRef.current?.click()
}
fileInputRef.current?.click();
};
// Handle drag events
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (isDisabled) return
if (isDisabled) return;
const droppedFiles = e.dataTransfer.files
const droppedFiles = e.dataTransfer.files;
// Only process image files
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/"),
)
file.type.startsWith("image/")
);
const { validFiles, errors } = validateFiles(imageFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
if (imageFiles.length > 0) {
onFileChange([...files, ...imageFiles]);
}
}
};
// Handle clearing conversation and diagram
const handleClear = () => {
onClearChat()
setShowClearDialog(false)
}
onClearChat();
setShowClearDialog(false);
};
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
className={`w-full space-y-2 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
? "border-2 border-dashed border-primary p-4 rounded-lg bg-muted/20"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
<Textarea
ref={textareaRef}
value={input}
onChange={onChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe what changes you want to make to the diagram
or upload(paste) an image to replicate a diagram.
(Press Cmd/Ctrl + Enter to send)"
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[80px] resize-none transition-all duration-200 px-1 py-0"
/>
<div className="flex items-center gap-2">
<div className="mr-auto">
<ButtonWithTooltip
type="button"
variant="ghost"
size="icon"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear current conversation and diagram"
>
<RotateCcw className="mr-2 h-4 w-4" />
</ButtonWithTooltip>
{/* Warning Modal */}
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
</div>
)}
<div className="flex gap-2">
{/* History Button */}
<ButtonWithTooltip
type="button"
variant="outline"
size="icon"
onClick={() => onToggleHistory(true)}
disabled={
isDisabled ||
diagramHistory.length === 0
}
title="Diagram History"
tooltipContent="View diagram history"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
{/* Input container */}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or paste an image..."
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={triggerFileInput}
disabled={isDisabled}
title="Upload image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
{/* Left actions */}
<div className="flex items-center gap-1">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear conversation"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</ButtonWithTooltip>
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowThemeWarning(true)}
tooltipContent={
drawioUi === "min"
? "Switch to Sketch theme"
: "Switch to Minimal theme"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{drawioUi === "min" ? (
<PenTool className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</ButtonWithTooltip>
<Dialog
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription>
Switching themes will reload the diagram
editor and clear any unsaved changes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setShowThemeWarning(false)
}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat()
onToggleDrawioUi()
setShowThemeWarning(false)
}}
>
Switch Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Right actions */}
<div className="flex items-center gap-1">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent="Diagram history"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent="Save diagram"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload image"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
disabled={isDisabled}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? "Sending..." : "Send message"
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
Send
</>
)}
</Button>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
disabled={isDisabled}
/>
</div>
<Button
type="submit"
disabled={isDisabled || !input.trim()}
className="transition-opacity"
aria-label={
isDisabled
? "Sending message..."
: "Send message"
}
>
{isDisabled ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Send
</Button>
</div>
</form>
)
);
}

View File

@@ -1,742 +1,217 @@
"use client"
"use client";
import type { UIMessage } from "ai"
import type React from "react";
import { useRef, useEffect, useState, useCallback } from "react";
import Image from "next/image";
import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai";
import { convertToLegalXml, replaceNodes } from "@/lib/utils";
import {
Check,
ChevronDown,
ChevronUp,
Copy,
Cpu,
Minus,
Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
convertToLegalXml,
replaceNodes,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface EditPair {
search: string
replace: string
}
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
}
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return (
<div className="space-y-3">
{edits.map((edit, index) => (
<div
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
Change {index + 1}
</span>
</div>
<div className="divide-y divide-border/30">
{/* Search (old) */}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Minus className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search}
</pre>
</div>
{/* Replace (new) */}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace}
</pre>
</div>
</div>
</div>
))}
</div>
)
}
import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return ""
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
import { useDiagram } from "@/contexts/diagram-context";
interface ChatMessageDisplayProps {
messages: UIMessage[]
setInput: (input: string) => void
setFiles: (files: File[]) => void
sessionId?: string
onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void
messages: UIMessage[];
error?: Error | null;
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
}
export function ChatMessageDisplay({
messages,
error,
setInput,
setFiles,
sessionId,
onRegenerate,
onEditMessage,
}: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
const processedToolCalls = useRef<Set<string>>(new Set())
const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousXML = useRef<string>("");
const processedToolCalls = useRef<Set<string>>(new Set());
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
string | null
>(null)
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
const [editingMessageId, setEditingMessageId] = useState<string | null>(
null,
)
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
// Toggle off if already selected
if (feedback[messageId] === value) {
setFeedback((prev) => {
const next = { ...prev }
delete next[messageId]
return next
})
return
}
setFeedback((prev) => ({ ...prev, [messageId]: value }))
try {
await fetch("/api/log-feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId,
feedback: value,
sessionId,
}),
})
} catch (error) {
console.warn("Failed to log feedback:", error)
}
}
{}
);
const handleDisplayChart = useCallback(
(xml: string) => {
const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml)
const currentXml = xml || "";
const convertedXml = convertToLegalXml(currentXml);
if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml)
const validationError = validateMxCellStructure(replacedXML)
if (!validationError) {
previousXML.current = convertedXml
onDisplayChart(replacedXML)
} else {
console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
}
previousXML.current = convertedXml;
const replacedXML = replaceNodes(chartXML, convertedXml);
onDisplayChart(replacedXML);
}
},
[chartXML, onDisplayChart],
)
[chartXML, onDisplayChart]
);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages])
useEffect(() => {
if (editingMessageId && editTextareaRef.current) {
editTextareaRef.current.focus()
}
}, [editingMessageId])
}, [messages]);
// Handle tool invocations and update diagram when needed
useEffect(() => {
messages.forEach((message) => {
if (message.parts) {
message.parts.forEach((part) => {
message.parts.forEach((part: any) => {
if (part.type?.startsWith("tool-")) {
const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
const { toolCallId, state } = part;
// Auto-collapse args when diagrams are generated
if (state === "output-available") {
setExpandedTools((prev) => ({
...prev,
[toolCallId]: false,
}))
}));
}
// Handle diagram updates for display_diagram tool
if (
part.type === "tool-display_diagram" &&
input?.xml
part.input?.xml
) {
const xml = input.xml as string
// For streaming input, always update to show streaming
if (
state === "input-streaming" ||
state === "input-available"
) {
handleDisplayChart(xml)
} else if (
handleDisplayChart(part.input.xml);
}
// For completed calls, only update if not processed yet
else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId)
handleDisplayChart(part.input.xml);
processedToolCalls.current.add(toolCallId);
}
}
}
})
});
}
})
}, [messages, handleDisplayChart])
});
}, [messages, handleDisplayChart]);
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const renderToolPart = (part: any) => {
const callId = part.toolCallId;
const { state, input, output } = part;
const isExpanded = expandedTools[callId] ?? true;
const toolName = part.type?.replace("tool-", "");
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
default:
return name
}
}
}));
};
return (
<div
key={callId}
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
className="p-4 my-2 text-gray-500 border border-gray-300 rounded"
>
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
Complete
</span>
)}
{state === "output-error" && (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="text-xs">Tool: {toolName}</div>
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
className="text-xs text-gray-500 hover:text-gray-700"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
{isExpanded ? "Hide Args" : "Show Args"}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
{input && isExpanded && (
<div className="mt-1 font-mono text-xs overflow-hidden">
{typeof input === "object" &&
Object.keys(input).length > 0 &&
`Input: ${JSON.stringify(input, null, 2)}`}
</div>
)}
<div className="mt-2 text-sm">
{state === "input-streaming" ? (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : state === "output-available" ? (
<div className="text-green-600">
{output || (toolName === "display_diagram"
? "Diagram generated"
: toolName === "edit_diagram"
? "Diagram edited"
: "Tool executed")}
</div>
) : state === "output-error" ? (
<div className="text-red-600">
{output || (toolName === "display_diagram"
? "Error generating diagram"
: toolName === "edit_diagram"
? "Error editing diagram"
: "Tool error")}
</div>
) : null}
</div>
)}
{output && state === "output-error" && (
<div className="px-4 py-3 border-t border-border/40 text-sm text-red-600">
{output}
</div>
)}
</div>
</div>
)
}
);
};
return (
<ScrollArea className="h-full w-full scrollbar-thin">
<ScrollArea className="h-full pr-4">
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText =
message.role === "user"
? getMessageTextContent(message)
: ""
const isLastAssistantMessage =
message.role === "assistant" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "assistant"))
const isLastUserMessage =
message.role === "user" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id
return (
<div
key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{
animationDelay: `${messageIndex * 50}ms`,
}}
>
{message.role === "user" &&
userMessageText &&
!isEditing && (
<div className="flex items-center gap-1 self-center mr-2">
{/* Edit button - only on last user message */}
{onEditMessage &&
isLastUserMessage && (
<button
type="button"
onClick={() => {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() =>
copyMessageToClipboard(
message.id,
userMessageText,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={
copiedMessageId ===
message.id
? "Copied!"
: copyFailedMessageId ===
message.id
? "Failed to copy"
: "Copy message"
}
>
{copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId ===
message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
<div className="max-w-[85%] min-w-0">
{/* Edit mode for user messages */}
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
<textarea
ref={editTextareaRef}
value={editText}
onChange={(e) =>
setEditText(e.target.value)
}
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
rows={Math.min(
editText.split("\n")
.length + 1,
6,
)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setEditingMessageId(
null,
)
setEditText("")
} else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
}
}
}}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setEditingMessageId(
null,
)
setEditText("")
messages.map((message) => (
<div
key={message.id}
className={`mb-4 ${
message.role === "user" ? "text-right" : "text-left"
}`}
>
<div
className={`inline-block px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{message.parts?.map((part: any, index: number) => {
switch (part.type) {
case "text":
return (
<div key={index}>{part.text}</div>
);
case "file":
return (
<div key={index} className="mt-2">
<Image
src={part.url}
width={200}
height={200}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-md border"
style={{
objectFit: "contain",
}}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => {
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
}
}}
disabled={!editText.trim()}
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Save & Submit
</button>
/>
</div>
</div>
) : (
/* Text content in bubble */
message.parts?.some(
(part) =>
part.type === "text" ||
part.type === "file",
) && (
<div
className={`px-4 py-3 text-sm leading-relaxed ${
message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
: message.role ===
"system"
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
role={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "button"
: undefined
}
tabIndex={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? 0
: undefined
}
onClick={() => {
if (
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}
}}
onKeyDown={(e) => {
if (
(e.key === "Enter" ||
e.key === " ") &&
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
e.preventDefault()
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}
}}
title={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "Click to edit"
: undefined
}
>
{message.parts?.map(
(part, index) => {
switch (part.type) {
case "text":
return (
<div
key={`${message.id}-text-${index}`}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert"
}`}
>
<ReactMarkdown>
{
part.text
}
</ReactMarkdown>
</div>
)
case "file":
return (
<div
key={`${message.id}-file-${part.url}`}
className="mt-2"
>
<Image
src={
part.url
}
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit:
"contain",
}}
/>
</div>
)
default:
return null
}
},
)}
</div>
)
)}
{/* Tool calls outside bubble */}
{message.parts?.map((part) => {
);
default:
if (part.type?.startsWith("tool-")) {
return renderToolPart(
part as ToolPartLike,
)
return renderToolPart(part);
}
return null
})}
{/* Action buttons for assistant messages */}
{message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2">
{/* Copy button */}
<button
type="button"
onClick={() =>
copyMessageToClipboard(
message.id,
getMessageTextContent(
message,
),
)
}
className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId ===
message.id
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
}`}
title={
copiedMessageId ===
message.id
? "Copied!"
: "Copy response"
}
>
{copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Regenerate button - only on last assistant message */}
{onRegenerate &&
isLastAssistantMessage && (
<button
type="button"
onClick={() =>
onRegenerate(
messageIndex,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{/* Divider */}
<div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */}
<button
type="button"
onClick={() =>
submitFeedback(
message.id,
"good",
)
}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] ===
"good"
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`}
title="Good response"
>
<ThumbsUp className="h-3.5 w-3.5" />
</button>
{/* Thumbs down */}
<button
type="button"
onClick={() =>
submitFeedback(
message.id,
"bad",
)
}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] ===
"bad"
? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`}
title="Bad response"
>
<ThumbsDown className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
)
})}
return null;
}
})}
</div>
</div>
))
)}
{error && (
<div className="text-red-500 text-sm mt-2">
Error: {error.message}
</div>
)}
<div ref={messagesEndRef} />
</ScrollArea>
)
);
}

View File

@@ -1,108 +1,71 @@
"use client"
"use client";
import type React from "react";
import { useRef, useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import Link from "next/link";
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
CheckCircle,
PanelRightClose,
PanelRightOpen,
Settings,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { ChatInput } from "@/components/chat-input";
import { ChatMessageDisplay } from "./chat-message-display";
import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
isMobile?: boolean
isVisible: boolean;
onToggleVisibility: () => void;
}
export default function ChatPanel({
isVisible,
onToggleVisibility,
drawioUi,
onToggleDrawioUi,
isMobile = false,
}: ChatPanelProps) {
export default function ChatPanel({ isVisible, onToggleVisibility }: ChatPanelProps) {
const {
loadDiagram: onDisplayChart,
handleExport: onExport,
handleExportWithoutHistory,
resolverRef,
chartXML,
clearDiagram,
} = useDiagram()
} = useDiagram();
const onFetchChart = (saveToHistory = true) => {
const onFetchChart = () => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
resolverRef.current = resolve;
}
onExport();
}),
new Promise<string>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Chart export timed out after 10 seconds",
),
),
10000,
),
),
])
}
setTimeout(() => reject(new Error("Chart export timed out after 10 seconds")), 10000)
)
]);
};
// Add a step counter to track updates
const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState("")
// Add state for file attachments
const [files, setFiles] = useState<File[]>([]);
// Add state for showing the history dialog
const [showHistory, setShowHistory] = useState(false);
// Check if access code is required on mount
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
.catch(() => setAccessCodeRequired(false))
}, [])
// Convert File[] to FileList for experimental_attachments
const createFileList = (files: File[]): FileList => {
const dt = new DataTransfer();
files.forEach((file) => dt.items.add(file));
return dt.files;
};
// Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
)
// Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML)
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Add state for input management
const [input, setInput] = useState("");
// Remove the currentXmlRef and related useEffect
const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
@@ -110,72 +73,40 @@ export default function ChatPanel({
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml)
if (validationError) {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: validationError,
})
} else {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
}
// Diagram is handled streamingly in the ChatMessageDisplay component
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
});
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>
}
edits: Array<{ search: string; replace: string }>;
};
let currentXml = ""
let currentXml = '';
try {
console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length,
)
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO...",
)
currentXml = await onFetchChart(false)
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length,
)
}
// Fetch current chart XML
currentXml = await onFetchChart();
const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits)
// Apply edits using the utility function
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
onDisplayChart(editedXml)
// Load the edited diagram
onDisplayChart(editedXml);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
})
console.log("[edit_diagram] Success")
});
} catch (error) {
console.error("[edit_diagram] Failed:", error)
console.error("Edit diagram failed:", error);
const errorMessage =
error instanceof Error
? error.message
: String(error)
const errorMessage = error instanceof Error ? error.message : String(error);
// Provide detailed error with current diagram XML
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
@@ -183,394 +114,169 @@ export default function ChatPanel({
Current diagram XML:
\`\`\`xml
${currentXml || "No XML available"}
${currentXml}
\`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
})
});
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: error.message,
parts: [{ type: "text" as const, text: error.message }],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
console.error("Chat error:", error);
},
})
const messagesEndRef = useRef<HTMLDivElement>(null)
});
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages])
}, [messages]);
// Debug: Log status changes
useEffect(() => {
console.log('[ChatPanel] Status changed to:', status);
}, [status]);
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"
e.preventDefault();
const isProcessing = status === "streaming" || status === "submitted";
if (input.trim() && !isProcessing) {
try {
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
// Fetch chart data before sending message
let chartXml = await onFetchChart();
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml
// Format the XML to ensure consistency
chartXml = formatXML(chartXml);
const parts: any[] = [{ type: "text", text: input }]
// Create message parts
const parts: any[] = [{ type: "text", text: input }];
// Add file parts if files exist
if (files.length > 0) {
for (const file of files) {
const reader = new FileReader()
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () =>
resolve(reader.result as string)
reader.readAsDataURL(file)
})
resolve(reader.result as string);
reader.readAsDataURL(file);
});
parts.push({
type: "file",
url: dataUrl,
mediaType: file.type,
})
});
}
}
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage(
{ parts },
{
body: {
xml: chartXml,
sessionId,
},
headers: {
"x-access-code": accessCode,
},
},
)
}
);
setInput("")
setFiles([])
// Clear input and files after submission
setInput("");
setFiles([]);
} catch (error) {
console.error("Error fetching chart data:", error)
console.error("Error fetching chart data:", error);
}
}
}
};
// Handle input change
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setInput(e.target.value)
}
setInput(e.target.value);
};
// Helper function to handle file changes
const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles)
}
setFiles(newFiles);
};
const handleRegenerate = async (messageIndex: number) => {
const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return
// Find the user message before this assistant message
let userMessageIndex = messageIndex - 1
while (
userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user"
) {
userMessageIndex--
}
if (userMessageIndex < 0) return
const userMessage = messages[userMessageIndex]
const userParts = userMessage.parts
// Get the text from the user message
const textPart = userParts?.find((p: any) => p.type === "text")
if (!textPart) return
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
userMessageIndex,
)
return
}
// Restore the diagram to the saved state
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > userMessageIndex) {
xmlSnapshotsRef.current.delete(key)
}
}
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, userMessageIndex)
flushSync(() => {
setMessages(newMessages)
})
// Now send the message after state is guaranteed to be updated
sendMessage(
{ parts: userParts },
{
body: {
xml: savedXml,
sessionId,
},
},
)
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return
const message = messages[messageIndex]
if (!message || message.role !== "user") return
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
messageIndex,
)
return
}
// Restore the diagram to the saved state
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > messageIndex) {
xmlSnapshotsRef.current.delete(key)
}
}
// Create new parts with updated text
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
return { ...part, text: newText }
}
return part
}) || [{ type: "text", text: newText }]
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, messageIndex)
flushSync(() => {
setMessages(newMessages)
})
// Now send the edited message after state is guaranteed to be updated
sendMessage(
{ parts: newParts },
{
body: {
xml: savedXml,
sessionId,
},
},
)
}
// Collapsed view (desktop only)
if (!isVisible && !isMobile) {
// Collapsed view when chat is hidden
if (!isVisible) {
return (
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
<Card className="h-full flex flex-col rounded-none py-0 gap-0 items-center justify-start pt-4">
<ButtonWithTooltip
tooltipContent="Show chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent transition-colors"
>
<PanelRightOpen className="h-5 w-5 text-muted-foreground" />
<PanelRightOpen className="h-5 w-5" />
</ButtonWithTooltip>
<div
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
style={{
writingMode: "vertical-rl",
transform: "rotate(180deg)",
}}
className="text-sm text-gray-500 mt-8"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
AI Chat
Chat
</div>
</div>
)
</Card>
);
}
// Full view
// Full view when chat is visible
return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<Toaster
position="bottom-center"
richColors
style={{ position: "absolute" }}
/>
{/* Header */}
<header
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Image
src="/favicon.ico"
alt="Next AI Drawio"
width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28}
className="rounded"
/>
<h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
>
Next AI Drawio
</h1>
</div>
{!isMobile && (
<Link
href="/about"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
{!isMobile && (
<ButtonWithTooltip
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 6 hours of stability. If issues persist, please report on GitHub."
variant="ghost"
size="icon"
className="h-6 w-6 text-green-500 hover:text-green-600"
>
<CheckCircle className="h-4 w-4" />
</ButtonWithTooltip>
)}
</div>
<div className="flex items-center gap-1">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
{accessCodeRequired && (
<ButtonWithTooltip
tooltipContent="Settings"
variant="ghost"
size="icon"
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
>
<Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
)}
{!isMobile && (
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent"
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
</div>
<Card className="h-full flex flex-col rounded-none py-0 gap-0">
<CardHeader className="p-4 flex flex-row justify-between items-center">
<div className="flex items-center gap-3">
<CardTitle>Next-AI-Drawio</CardTitle>
<Link href="/about" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
About
</Link>
</div>
</header>
{/* Messages */}
<main className="flex-1 w-full overflow-hidden">
<div className="flex items-center gap-2">
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
>
<PanelRightClose className="h-5 w-5" />
</ButtonWithTooltip>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<FaGithub className="w-6 h-6" />
</a>
</div>
</CardHeader>
<CardContent className="flex-grow overflow-hidden px-2">
<ChatMessageDisplay
messages={messages}
error={error}
setInput={setInput}
setFiles={handleFileChange}
sessionId={sessionId}
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
/>
</main>
</CardContent>
{/* Input */}
<footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
>
<CardFooter className="p-2">
<ChatInput
input={input}
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={() => {
setMessages([])
clearDiagram()
setSessionId(
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`,
)
xmlSnapshotsRef.current.clear()
setMessages([]);
clearDiagram();
}}
files={files}
onFileChange={handleFileChange}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
/>
</footer>
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
/>
</div>
)
</CardFooter>
</Card>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
"use client"
"use client";
import Image from "next/image"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
@@ -10,32 +7,21 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import { useDiagram } from "@/contexts/diagram-context";
interface HistoryDialogProps {
showHistory: boolean
onToggleHistory: (show: boolean) => void
showHistory: boolean;
onToggleHistory: (show: boolean) => void;
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
onDisplayChart(diagramHistory[selectedIndex].xml)
handleClose()
}
}
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
@@ -59,12 +45,11 @@ export function HistoryDialog({
{diagramHistory.map((item, index) => (
<div
key={index}
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
selectedIndex === index
? "border-primary ring-2 ring-primary"
: ""
}`}
onClick={() => setSelectedIndex(index)}
className="border rounded-md p-2 cursor-pointer hover:border-primary transition-colors"
onClick={() => {
onDisplayChart(item.xml);
onToggleHistory(false);
}}
>
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
@@ -84,28 +69,14 @@ export function HistoryDialog({
)}
<DialogFooter>
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
Restore to Version {selectedIndex + 1}?
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
Cancel
</Button>
<Button onClick={handleConfirmRestore}>
Confirm
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
Close
</Button>
)}
<Button
variant="outline"
onClick={() => onToggleHistory(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}

View File

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

View File

@@ -1,128 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
]
interface SaveDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (filename: string, format: ExportFormat) => void
defaultFilename: string
}
export function SaveDialog({
open,
onOpenChange,
onSave,
defaultFilename,
}: SaveDialogProps) {
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
useEffect(() => {
if (open) {
setFilename(defaultFilename)
}
}, [open, defaultFilename])
const handleSave = () => {
const finalFilename = filename.trim() || defaultFilename
onSave(finalFilename, format)
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save Diagram</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Format</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Filename</label>
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter filename"
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
/>
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
{currentFormat?.extension || ".drawio"}
</span>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,84 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("")
useEffect(() => {
if (open) {
const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
}
}, [open])
const handleSave = () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure your access settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Access Code
</label>
<Input
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Required if the server has enabled access control.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

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

View File

@@ -1,187 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

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

View File

@@ -1,141 +0,0 @@
# AI Provider Configuration
This guide explains how to configure different AI model providers for next-ai-draw-io.
## Quick Start
1. Copy `.env.example` to `.env.local`
2. Set your API key for your chosen provider
3. Set `AI_MODEL` to your desired model
4. Run `npm run dev`
## Supported Providers
### Google Gemini
```bash
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
Optional custom endpoint:
```bash
GOOGLE_BASE_URL=https://your-custom-endpoint
```
### OpenAI
```bash
OPENAI_API_KEY=your_api_key
AI_MODEL=gpt-4o
```
Optional custom endpoint (for OpenAI-compatible services):
```bash
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### Anthropic
```bash
ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
ANTHROPIC_BASE_URL=https://your-custom-endpoint
```
### DeepSeek
```bash
DEEPSEEK_API_KEY=your_api_key
AI_MODEL=deepseek-chat
```
Optional custom endpoint:
```bash
DEEPSEEK_BASE_URL=https://your-custom-endpoint
```
### Azure OpenAI
```bash
AZURE_API_KEY=your_api_key
AI_MODEL=your-deployment-name
```
Optional custom endpoint:
```bash
AZURE_BASE_URL=https://your-resource.openai.azure.com
```
### AWS Bedrock
```bash
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
Note: On AWS (Amplify, Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
```bash
OPENROUTER_API_KEY=your_api_key
AI_MODEL=anthropic/claude-sonnet-4
```
Optional custom endpoint:
```bash
OPENROUTER_BASE_URL=https://your-custom-endpoint
```
### Ollama (Local)
```bash
AI_PROVIDER=ollama
AI_MODEL=llama3.2
```
Optional custom URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
```
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
```
## Model Capability Requirements
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
**Recommended models**:
- Claude Sonnet 4.5 / Opus 4.5
**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.
## Recommendations
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
- **Budget-friendly**: DeepSeek offers competitive pricing
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
- **Flexibility**: OpenRouter provides access to many models through a single API

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter
# Default: bedrock
AI_PROVIDER=bedrock
@@ -20,33 +20,16 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Anthropic (Direct) Configuration
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
# Google Generative AI Configuration
# GOOGLE_GENERATIVE_AI_API_KEY=...
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
# Azure OpenAI Configuration
# AZURE_RESOURCE_NAME=your-resource-name
# AZURE_API_KEY=...
# AZURE_BASE_URL=https://your-resource.openai.azure.com # Optional: Custom endpoint (overrides resourceName)
# Ollama (Local) Configuration
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
# OpenRouter Configuration
# OPENROUTER_API_KEY=sk-or-v1-...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
# DeepSeek Configuration
# DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
# Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code

View File

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

View File

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

View File

@@ -1,561 +0,0 @@
export interface CachedResponse {
promptText: string
hasImage: boolean
xml: string
}
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText:
"Give me a **animated connector** diagram of transformer's architecture",
hasImage: false,
xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="300" y="20" width="250" height="30" as="geometry"/>
</mxCell>
<mxCell id="input_embed" value="Input Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="80" y="480" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="pos_enc_left" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="80" y="420" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="encoder_box" value="ENCODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="60" y="180" width="160" height="220" as="geometry"/>
</mxCell>
<mxCell id="mha_enc" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="330" width="120" height="50" as="geometry"/>
</mxCell>
<mxCell id="add_norm1_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="280" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="ff_enc" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="240" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="add_norm2_enc" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="80" y="200" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="nx_enc" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
<mxGeometry x="30" y="275" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="output_embed" value="Output Embedding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="480" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="pos_enc_right" value="Positional Encoding" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="420" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="decoder_box" value="DECODER" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;verticalAlign=top;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="630" y="140" width="160" height="260" as="geometry"/>
</mxCell>
<mxCell id="masked_mha_dec" value="Masked Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="340" width="120" height="50" as="geometry"/>
</mxCell>
<mxCell id="add_norm1_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="290" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="mha_dec" value="Multi-Head&#xa;Attention" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="240" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="add_norm2_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="200" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="ff_dec" value="Feed Forward" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="160" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="add_norm3_dec" value="Add &amp; Norm" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
<mxGeometry x="650" y="120" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="nx_dec" value="Nx" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=2;" vertex="1" parent="1">
<mxGeometry x="790" y="255" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="linear" value="Linear" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="80" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="softmax" value="Softmax" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="650" y="40" width="120" height="30" as="geometry"/>
</mxCell>
<mxCell id="output" value="Output Probabilities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="640" y="0" width="140" height="30" as="geometry"/>
</mxCell>
<mxCell id="conn1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="input_embed" target="pos_enc_left">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="pos_enc_left" target="mha_enc">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;" edge="1" parent="1" source="mha_enc" target="add_norm1_enc">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;" edge="1" parent="1" source="add_norm1_enc" target="ff_enc">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;" edge="1" parent="1" source="ff_enc" target="add_norm2_enc">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn_cross" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=3;strokeColor=#9673a6;flowAnimation=1;dashed=1;" edge="1" parent="1" source="add_norm2_enc" target="mha_dec">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="400" y="215"/>
<mxPoint x="400" y="260"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="cross_label" value="K, V" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;" vertex="1" connectable="0" parent="conn_cross">
<mxGeometry x="-0.1" y="1" relative="1" as="geometry">
<mxPoint x="10" y="-9" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="conn6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;" edge="1" parent="1" source="output_embed" target="pos_enc_right">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d79b00;flowAnimation=1;" edge="1" parent="1" source="pos_enc_right" target="masked_mha_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;" edge="1" parent="1" source="masked_mha_dec" target="add_norm1_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;" edge="1" parent="1" source="add_norm1_dec" target="mha_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;" edge="1" parent="1" source="mha_dec" target="add_norm2_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#d6b656;flowAnimation=1;" edge="1" parent="1" source="add_norm2_dec" target="ff_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#82b366;flowAnimation=1;" edge="1" parent="1" source="ff_dec" target="add_norm3_dec">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#b85450;flowAnimation=1;" edge="1" parent="1" source="add_norm3_dec" target="linear">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#b85450;flowAnimation=1;" edge="1" parent="1" source="linear" target="softmax">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="conn15" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;strokeWidth=2;strokeColor=#6c8ebf;flowAnimation=1;" edge="1" parent="1" source="softmax" target="output">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="res1_enc" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="mha_enc" target="add_norm1_enc">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="50" y="355"/>
<mxPoint x="50" y="295"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="res2_enc" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="ff_enc" target="add_norm2_enc">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="50" y="255"/>
<mxPoint x="50" y="215"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="res1_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="masked_mha_dec" target="add_norm1_dec">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="800" y="365"/>
<mxPoint x="800" y="305"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="res2_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="mha_dec" target="add_norm2_dec">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="800" y="260"/>
<mxPoint x="800" y="215"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="res3_dec" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;strokeWidth=1.5;strokeColor=#999999;dashed=1;flowAnimation=1;" edge="1" parent="1" source="ff_dec" target="add_norm3_dec">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="800" y="175"/>
<mxPoint x="800" y="135"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="input_label" value="Inputs" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="110" y="530" width="60" height="20" as="geometry"/>
</mxCell>
<mxCell id="output_label" value="Outputs&#xa;(shifted right)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
</mxCell>
</root>`,
},
{
promptText: "Replicate this in aws style",
hasImage: true,
xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell>
<mxCell id="3" value="User" style="sketch=0;outlineConnect=0;fontColor=#232F3E;gradientColor=none;fillColor=#232F3D;strokeColor=none;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;pointerEvents=1;shape=mxgraph.aws4.user;rounded=1;" vertex="1" parent="1">
<mxGeometry x="80" y="240" width="78" height="78" as="geometry"/>
</mxCell>
<mxCell id="4" value="EC2" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#ED7100;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;rounded=1;" vertex="1" parent="1">
<mxGeometry x="560" y="240" width="78" height="78" as="geometry"/>
</mxCell>
<mxCell id="5" value="S3" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#7AA116;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.s3;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="120" width="78" height="78" as="geometry"/>
</mxCell>
<mxCell id="6" value="bedrock" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#01A88D;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.bedrock;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="260" width="78" height="78" as="geometry"/>
</mxCell>
<mxCell id="7" value="DynamoDB" style="sketch=0;points=[[0,0,0],[0.25,0,0],[0.5,0,0],[0.75,0,0],[1,0,0],[0,1,0],[0.25,1,0],[0.5,1,0],[0.75,1,0],[1,1,0],[0,0.25,0],[0,0.5,0],[0,0.75,0],[1,0.25,0],[1,0.5,0],[1,0.75,0]];outlineConnect=0;fontColor=#232F3E;fillColor=#C925D1;strokeColor=#ffffff;dashed=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;fontSize=14;fontStyle=0;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.dynamodb;rounded=1;" vertex="1" parent="1">
<mxGeometry x="960" y="400" width="78" height="78" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="3" target="4">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="350" as="sourcePoint"/>
<mxPoint x="450" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="9" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.25;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="5">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="10" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="6">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="" style="endArrow=classic;html=1;rounded=0;strokeColor=#232F3E;strokeWidth=2;exitX=1;exitY=0.75;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="4" target="7">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>
</root>`,
},
{
promptText: "Replicate this flowchart.",
hasImage: true,
xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="4" value="Lamp&lt;br&gt;plugged in?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="150" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="5" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="6">
<mxGeometry x="-0.2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="6" value="Plug in lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="420" y="220" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="7" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="4" target="8">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="8" value="Bulb&lt;br&gt;burned out?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#ffff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="400" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="9" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="10">
<mxGeometry x="-0.2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="10" value="Replace bulb" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="420" y="470" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="11" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#000000;strokeWidth=2;endArrow=block;endFill=1;fontSize=16;" edge="1" parent="1" source="8" target="12">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell>
</root>`,
},
{
promptText: "Draw a cat for me",
hasImage: false,
xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/>
</mxCell>
<mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1">
<mxGeometry x="280" y="120" width="50" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="390" y="120" width="50" height="60" as="geometry"/>
</mxCell>
<mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1">
<mxGeometry x="290" y="135" width="30" height="35" as="geometry"/>
</mxCell>
<mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="400" y="135" width="30" height="35" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="325" y="185" width="15" height="15" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="380" y="185" width="15" height="15" as="geometry"/>
</mxCell>
<mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1">
<mxGeometry x="350" y="210" width="20" height="15" as="geometry"/>
</mxCell>
<mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="220" as="sourcePoint"/>
<mxPoint x="340" y="235" as="targetPoint"/>
<Array as="points">
<mxPoint x="355" y="230"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="220" as="sourcePoint"/>
<mxPoint x="380" y="235" as="targetPoint"/>
<Array as="points">
<mxPoint x="365" y="230"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="200" as="sourcePoint"/>
<mxPoint x="260" y="195" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="210" as="sourcePoint"/>
<mxPoint x="260" y="210" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="220" as="sourcePoint"/>
<mxPoint x="260" y="225" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="200" as="sourcePoint"/>
<mxPoint x="460" y="195" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="210" as="sourcePoint"/>
<mxPoint x="460" y="210" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="220" as="sourcePoint"/>
<mxPoint x="460" y="225" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="285" y="250" width="150" height="180" as="geometry"/>
</mxCell>
<mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="315" y="280" width="90" height="120" as="geometry"/>
</mxCell>
<mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="410" width="40" height="50" as="geometry"/>
</mxCell>
<mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="380" y="410" width="40" height="50" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="285" y="340" as="sourcePoint"/>
<mxPoint x="240" y="260" as="targetPoint"/>
<Array as="points">
<mxPoint x="260" y="350"/>
<mxPoint x="240" y="320"/>
<mxPoint x="235" y="290"/>
</Array>
</mxGeometry>
</mxCell>
</root>`,
},
]
export function findCachedResponse(
promptText: string,
hasImage: boolean,
): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find(
(c) =>
c.promptText === promptText &&
c.hasImage === hasImage &&
c.xml !== "",
)
}

View File

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

View File

@@ -1,337 +0,0 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*/
// Default system prompt (~1400 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded.
## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered
- **Right panel**: Chat interface where you communicate with the user
You can read and modify diagrams by generating draw.io XML code through tool calls.
## App Features
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
You utilize the following tools:
---Tool1---
tool name: display_diagram
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
parameters: {
xml: string
}
---Tool2---
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
edits: Array<{search: string, replace: string}>
}
---End of tools---
IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
- Adapt artistic concepts into abstract diagram representations using available shapes
- Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components
Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Use compact, efficient layouts that fit the entire diagram in one view
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
Note that:
- Use proper tool calls to generate or edit diagrams;
- never return raw XML in text responses,
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:
- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters!
- Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...}
- Include complete elements (mxCell + mxGeometry) for reliable matching
- Preserve exact whitespace, indentation, and line breaks
- BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements
- GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"}
- For multiple changes, use separate edits in array
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
## Draw.io XML Structure Reference
Basic structure:
\`\`\`xml
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
</root>
</mxGraphModel>
\`\`\`
Note: All other mxCell elements go as siblings after id="1".
CRITICAL RULES:
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
Shape (vertex) example:
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\`
Connector (edge) example:
\`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
2. Every mxCell needs a unique id attribute
3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
4. Edge source/target attributes must reference existing cell IDs
5. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
6. Always start with the two root cells: <mxCell id="0"/><mxCell id="1" parent="0"/>
**Example with swimlanes and edges** (note: all mxCells are siblings under <root>):
\`\`\`xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
### edit_diagram Details
**CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
**Input Format:**
\`\`\`json
{
"edits": [
{
"search": "EXACT lines copied from current XML (preserve attribute order!)",
"replace": "Replacement lines"
}
]
}
\`\`\`
## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
1. Review the "Current diagram XML" in the system context
2. Identify the exact element(s) to modify by their unique id attribute
3. Include enough context to ensure uniqueness
### Pattern Construction Rules
**Rule 1: Always include the element's id attribute**
\`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\`
**Rule 2: Include complete XML elements when possible**
\`\`\`json
{
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
**Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
### Good vs Bad Patterns
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
2. **Second retry**: Expand context - include more surrounding lines
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
## Common Style Properties
### Shape Styles
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
- curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
- dashed=1, dashPattern=3 3, flowAnimation=1
### Text Styles
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic)
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
## Common Shape Types
### Basic Shapes
- Rectangle: rounded=0;whiteSpace=wrap;html=1;
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1;
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
- Diamond: rhombus;whiteSpace=wrap;html=1;
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
### Flowchart Shapes
- Process: rounded=1;whiteSpace=wrap;html=1;
- Decision: rhombus;whiteSpace=wrap;html=1;
- Start/End: ellipse;whiteSpace=wrap;html=1;
- Document: shape=document;whiteSpace=wrap;html=1;
- Database: shape=cylinder3;whiteSpace=wrap;html=1;
### Container Types
- Swimlane: swimlane;whiteSpace=wrap;html=1;
- Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;
## Container/Group Example
\`\`\`xml
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
</mxCell>
\`\`\`
## Example: Complete Flowchart
\`\`\`xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
`
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
// Model patterns that require extended prompt (4000 token cache minimum)
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
const EXTENDED_PROMPT_MODEL_PATTERNS = [
"claude-opus-4-5", // Matches any Opus 4.5 variant
"claude-haiku-4-5", // Matches any Haiku 4.5 variant
]
/**
* Get the appropriate system prompt based on the model ID
* Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum
* @param modelId - The AI model ID from environment
* @returns The system prompt string
*/
export function getSystemPrompt(modelId?: string): string {
const modelName = modelId || "AI"
let prompt: string
if (
modelId &&
EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>
modelId.includes(pattern),
)
) {
console.log(
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
)
prompt = EXTENDED_SYSTEM_PROMPT
} else {
console.log(
`[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`,
)
prompt = DEFAULT_SYSTEM_PROMPT
}
return prompt.replace("{{MODEL_NAME}}", modelName)
}

View File

@@ -1,9 +1,9 @@
import { type ClassValue, clsx } from "clsx"
import * as pako from "pako"
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import * as pako from 'pako';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs))
}
/**
@@ -12,48 +12,48 @@ export function cn(...inputs: ClassValue[]) {
* @param indent - The indentation string (default: ' ')
* @returns Formatted XML string
*/
export function formatXML(xml: string, indent: string = " "): string {
let formatted = ""
let pad = 0
export function formatXML(xml: string, indent: string = ' '): string {
let formatted = '';
let pad = 0;
// Remove existing whitespace between tags
xml = xml.replace(/>\s*</g, "><").trim()
// Remove existing whitespace between tags
xml = xml.replace(/>\s*</g, '><').trim();
// Split on tags
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean)
// Split on tags
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean);
tags.forEach((node) => {
if (node.match(/^<\/\w/)) {
// Closing tag - decrease indent
pad = Math.max(0, pad - 1)
formatted += indent.repeat(pad) + node + "\n"
} else if (node.match(/^<\w[^>]*[^/]>.*$/)) {
// Opening tag
formatted += indent.repeat(pad) + node
// Only add newline if next item is a tag
const nextIndex = tags.indexOf(node) + 1
if (nextIndex < tags.length && tags[nextIndex].startsWith("<")) {
formatted += "\n"
if (!node.match(/^<\w[^>]*\/>$/)) {
pad++
}
}
} else if (node.match(/^<\w[^>]*\/>$/)) {
// Self-closing tag
formatted += indent.repeat(pad) + node + "\n"
} else if (node.startsWith("<")) {
// Other tags (like <?xml)
formatted += indent.repeat(pad) + node + "\n"
} else {
// Text content
formatted += node
tags.forEach((node) => {
if (node.match(/^<\/\w/)) {
// Closing tag - decrease indent
pad = Math.max(0, pad - 1);
formatted += indent.repeat(pad) + node + '\n';
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
// Opening tag
formatted += indent.repeat(pad) + node;
// Only add newline if next item is a tag
const nextIndex = tags.indexOf(node) + 1;
if (nextIndex < tags.length && tags[nextIndex].startsWith('<')) {
formatted += '\n';
if (!node.match(/^<\w[^>]*\/>$/)) {
pad++;
}
})
}
} else if (node.match(/^<\w[^>]*\/>$/)) {
// Self-closing tag
formatted += indent.repeat(pad) + node + '\n';
} else if (node.startsWith('<')) {
// Other tags (like <?xml)
formatted += indent.repeat(pad) + node + '\n';
} else {
// Text content
formatted += node;
}
});
return formatted.trim()
return formatted.trim();
}
/**
/**
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly.
* Additionally, if an <mxCell> tag does not have an mxGeometry child (e.g. <mxCell id="3">),
* it removes that tag from the output.
@@ -61,26 +61,24 @@ export function formatXML(xml: string, indent: string = " "): string {
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements.
*/
export function convertToLegalXml(xmlString: string): string {
// This regex will match either self-closing <mxCell .../> or a block element
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
let match: RegExpExecArray | null
let result = "<root>\n"
// This regex will match either self-closing <mxCell .../> or a block element
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g;
let match: RegExpExecArray | null;
let result = "<root>\n";
while ((match = regex.exec(xmlString)) !== null) {
// match[0] contains the entire matched mxCell block
// Indent each line of the matched block for readability.
const formatted = match[0]
.split("\n")
.map((line) => " " + line.trim())
.join("\n")
result += formatted + "\n"
}
result += "</root>"
while ((match = regex.exec(xmlString)) !== null) {
// match[0] contains the entire matched mxCell block
// Indent each line of the matched block for readability.
const formatted = match[0].split('\n').map(line => " " + line.trim()).join('\n');
result += formatted + "\n";
}
result += "</root>";
return result
return result;
}
/**
* Replace nodes in a Draw.io XML diagram
* @param currentXML - The original Draw.io XML string
@@ -88,130 +86,94 @@ export function convertToLegalXml(xmlString: string): string {
* @returns The updated XML string with replaced nodes
*/
export function replaceNodes(currentXML: string, nodes: string): string {
// Check for valid inputs
if (!currentXML || !nodes) {
throw new Error("Both currentXML and nodes must be provided")
// Check for valid inputs
if (!currentXML || !nodes) {
throw new Error("Both currentXML and nodes must be provided");
}
try {
// Parse the XML strings to create DOM objects
const parser = new DOMParser();
const currentDoc = parser.parseFromString(currentXML, "text/xml");
// Handle nodes input - if it doesn't contain <root>, wrap it
let nodesString = nodes;
if (!nodes.includes("<root>")) {
nodesString = `<root>${nodes}</root>`;
}
try {
// Parse the XML strings to create DOM objects
const parser = new DOMParser()
const currentDoc = parser.parseFromString(currentXML, "text/xml")
const nodesDoc = parser.parseFromString(nodesString, "text/xml");
// Handle nodes input - if it doesn't contain <root>, wrap it
let nodesString = nodes
if (!nodes.includes("<root>")) {
nodesString = `<root>${nodes}</root>`
}
// Find the root element in the current document
let currentRoot = currentDoc.querySelector("mxGraphModel > root");
if (!currentRoot) {
// If no root element is found, create the proper structure
const mxGraphModel = currentDoc.querySelector("mxGraphModel") ||
currentDoc.createElement("mxGraphModel");
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
if (!currentDoc.contains(mxGraphModel)) {
currentDoc.appendChild(mxGraphModel);
}
// Find the root element in the current document
let currentRoot = currentDoc.querySelector("mxGraphModel > root")
if (!currentRoot) {
// If no root element is found, create the proper structure
const mxGraphModel =
currentDoc.querySelector("mxGraphModel") ||
currentDoc.createElement("mxGraphModel")
if (!currentDoc.contains(mxGraphModel)) {
currentDoc.appendChild(mxGraphModel)
}
currentRoot = currentDoc.createElement("root")
mxGraphModel.appendChild(currentRoot)
}
// Find the root element in the nodes document
const nodesRoot = nodesDoc.querySelector("root")
if (!nodesRoot) {
throw new Error(
"Invalid nodes: Could not find or create <root> element",
)
}
// Clear all existing child elements from the current root
while (currentRoot.firstChild) {
currentRoot.removeChild(currentRoot.firstChild)
}
// Ensure the base cells exist
const hasCell0 = Array.from(nodesRoot.childNodes).some(
(node) =>
node.nodeName === "mxCell" &&
(node as Element).getAttribute("id") === "0",
)
const hasCell1 = Array.from(nodesRoot.childNodes).some(
(node) =>
node.nodeName === "mxCell" &&
(node as Element).getAttribute("id") === "1",
)
// Copy all child nodes from the nodes root to the current root
Array.from(nodesRoot.childNodes).forEach((node) => {
const importedNode = currentDoc.importNode(node, true)
currentRoot.appendChild(importedNode)
})
// Add default cells if they don't exist
if (!hasCell0) {
const cell0 = currentDoc.createElement("mxCell")
cell0.setAttribute("id", "0")
currentRoot.insertBefore(cell0, currentRoot.firstChild)
}
if (!hasCell1) {
const cell1 = currentDoc.createElement("mxCell")
cell1.setAttribute("id", "1")
cell1.setAttribute("parent", "0")
// Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
if (cell0?.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling)
} else {
currentRoot.appendChild(cell1)
}
}
// Convert the modified DOM back to a string
const serializer = new XMLSerializer()
return serializer.serializeToString(currentDoc)
} catch (error) {
throw new Error(`Error replacing nodes: ${error}`)
currentRoot = currentDoc.createElement("root");
mxGraphModel.appendChild(currentRoot);
}
}
/**
* Create a character count dictionary from a string
* Used for attribute-order agnostic comparison
*/
function charCountDict(str: string): Map<string, number> {
const dict = new Map<string, number>()
for (const char of str) {
dict.set(char, (dict.get(char) || 0) + 1)
// Find the root element in the nodes document
const nodesRoot = nodesDoc.querySelector("root");
if (!nodesRoot) {
throw new Error("Invalid nodes: Could not find or create <root> element");
}
return dict
}
/**
* Compare two strings by character frequency (order-agnostic)
*/
function sameCharFrequency(a: string, b: string): boolean {
const trimmedA = a.trim()
const trimmedB = b.trim()
if (trimmedA.length !== trimmedB.length) return false
const dictA = charCountDict(trimmedA)
const dictB = charCountDict(trimmedB)
if (dictA.size !== dictB.size) return false
for (const [char, count] of dictA) {
if (dictB.get(char) !== count) return false
// Clear all existing child elements from the current root
while (currentRoot.firstChild) {
currentRoot.removeChild(currentRoot.firstChild);
}
return true
// Ensure the base cells exist
const hasCell0 = Array.from(nodesRoot.childNodes).some(
node => node.nodeName === "mxCell" &&
(node as Element).getAttribute("id") === "0"
);
const hasCell1 = Array.from(nodesRoot.childNodes).some(
node => node.nodeName === "mxCell" &&
(node as Element).getAttribute("id") === "1"
);
// Copy all child nodes from the nodes root to the current root
Array.from(nodesRoot.childNodes).forEach(node => {
const importedNode = currentDoc.importNode(node, true);
currentRoot.appendChild(importedNode);
});
// Add default cells if they don't exist
if (!hasCell0) {
const cell0 = currentDoc.createElement("mxCell");
cell0.setAttribute("id", "0");
currentRoot.insertBefore(cell0, currentRoot.firstChild);
}
if (!hasCell1) {
const cell1 = currentDoc.createElement("mxCell");
cell1.setAttribute("id", "1");
cell1.setAttribute("parent", "0");
// Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]');
if (cell0 && cell0.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling);
} else {
currentRoot.appendChild(cell1);
}
}
// Convert the modified DOM back to a string
const serializer = new XMLSerializer();
return serializer.serializeToString(currentDoc);
} catch (error) {
throw new Error(`Error replacing nodes: ${error}`);
}
}
/**
@@ -221,388 +183,193 @@ function sameCharFrequency(a: string, b: string): boolean {
* @returns The updated XML string with replacements applied
*/
export function replaceXMLParts(
xmlContent: string,
searchReplacePairs: Array<{ search: string; replace: string }>,
xmlContent: string,
searchReplacePairs: Array<{ search: string; replace: string }>
): string {
// Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent)
let lastProcessedIndex = 0
// Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent);
let lastProcessedIndex = 0;
for (const { search, replace } of searchReplacePairs) {
// Also format the search content for consistency
const formattedSearch = formatXML(search)
const searchLines = formattedSearch.split("\n")
for (const { search, replace } of searchReplacePairs) {
// Also format the search content for consistency
const formattedSearch = formatXML(search);
const searchLines = formattedSearch.split('\n');
// Split into lines for exact line matching
const resultLines = result.split("\n")
// Split into lines for exact line matching
const resultLines = result.split('\n');
// Remove trailing empty line if exists (from the trailing \n in search content)
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
// Find the line number where lastProcessedIndex falls
let startLineNum = 0
let currentIndex = 0
while (
currentIndex < lastProcessedIndex &&
startLineNum < resultLines.length
) {
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
startLineNum++
}
// Try to find exact match starting from lastProcessedIndex
let matchFound = false
let matchStartLine = -1
let matchEndLine = -1
// First try: exact match
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
if (resultLines[i + j] !== searchLines[j]) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
}
// Second try: line-trimmed match (fallback)
if (!matchFound) {
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = resultLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
}
}
// Third try: substring match as last resort (for single-line XML)
if (!matchFound) {
// Try to find as a substring in the entire content
const searchStr = search.trim()
const resultStr = result
const index = resultStr.indexOf(searchStr)
if (index !== -1) {
// Found as substring - replace it
result =
resultStr.substring(0, index) +
replace.trim() +
resultStr.substring(index + searchStr.length)
// Re-format after substring replacement
result = formatXML(result)
continue // Skip the line-based replacement below
}
}
// Fourth try: character frequency match (attribute-order agnostic)
// This handles cases where the model generates XML with different attribute order
if (!matchFound) {
for (
let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
if (
!sameCharFrequency(resultLines[i + j], searchLines[j])
) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
}
}
// Fifth try: Match by mxCell id attribute
// Extract id from search pattern and find the element with that id
if (!matchFound) {
const idMatch = search.match(/id="([^"]+)"/)
if (idMatch) {
const searchId = idMatch[1]
// Find lines that contain this id
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(`id="${searchId}"`)) {
// Found the element with matching id
// Now find the extent of this element (it might span multiple lines)
let endLine = i + 1
const line = resultLines[i].trim()
// Check if it's a self-closing tag or has children
if (!line.endsWith("/>")) {
// Find the closing tag or the end of the mxCell block
let depth = 1
while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim()
if (
currentLine.startsWith("<") &&
!currentLine.startsWith("</") &&
!currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
}
}
if (!matchFound) {
throw new Error(
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
)
}
// Replace the matched lines
const replaceLines = replace.split("\n")
// Remove trailing empty line if exists
if (replaceLines[replaceLines.length - 1] === "") {
replaceLines.pop()
}
// Perform the replacement
const newResultLines = [
...resultLines.slice(0, matchStartLine),
...replaceLines,
...resultLines.slice(matchEndLine),
]
result = newResultLines.join("\n")
// Update lastProcessedIndex to the position after the replacement
lastProcessedIndex = 0
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
lastProcessedIndex += newResultLines[i].length + 1
}
// Remove trailing empty line if exists (from the trailing \n in search content)
if (searchLines[searchLines.length - 1] === '') {
searchLines.pop();
}
return result
}
/**
* Validates draw.io XML structure for common issues
* @param xml - The XML string to validate
* @returns null if valid, error message string if invalid
*/
export function validateMxCellStructure(xml: string): string | null {
const parser = new DOMParser()
const doc = parser.parseFromString(xml, "text/xml")
// Check for XML parsing errors (includes unescaped special characters)
const parseError = doc.querySelector("parsererror")
if (parseError) {
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.`
// Find the line number where lastProcessedIndex falls
let startLineNum = 0;
let currentIndex = 0;
while (currentIndex < lastProcessedIndex && startLineNum < resultLines.length) {
currentIndex += resultLines[startLineNum].length + 1; // +1 for \n
startLineNum++;
}
// Get all mxCell elements once for all validations
const allCells = doc.querySelectorAll("mxCell")
// Try to find exact match starting from lastProcessedIndex
let matchFound = false;
let matchStartLine = -1;
let matchEndLine = -1;
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
const cellIds = new Set<string>()
const duplicateIds: string[] = []
const nestedCells: string[] = []
const orphanCells: string[] = []
const invalidParents: { id: string; parent: string }[] = []
const edgesToValidate: {
id: string
source: string | null
target: string | null
}[] = []
// First try: exact match
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
let matches = true;
allCells.forEach((cell) => {
const id = cell.getAttribute("id")
const parent = cell.getAttribute("parent")
const isEdge = cell.getAttribute("edge") === "1"
// Check for duplicate IDs
if (id) {
if (cellIds.has(id)) {
duplicateIds.push(id)
} else {
cellIds.add(id)
}
for (let j = 0; j < searchLines.length; j++) {
if (resultLines[i + j] !== searchLines[j]) {
matches = false;
break;
}
}
// Check for nested mxCell (parent element is also mxCell)
if (cell.parentElement?.tagName === "mxCell") {
nestedCells.push(id || "unknown")
}
// Check parent attribute (skip root cell id="0")
if (id !== "0") {
if (!parent) {
if (id) orphanCells.push(id)
} else {
// Store for later validation (after all IDs collected)
invalidParents.push({ id: id || "unknown", parent })
}
}
// Collect edges for connection validation
if (isEdge) {
edgesToValidate.push({
id: id || "unknown",
source: cell.getAttribute("source"),
target: cell.getAttribute("target"),
})
}
})
// Return errors in priority order
if (nestedCells.length > 0) {
return `Invalid XML: Found nested mxCell elements (IDs: ${nestedCells.slice(0, 3).join(", ")}). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure.`
if (matches) {
matchStartLine = i;
matchEndLine = i + searchLines.length;
matchFound = true;
break;
}
}
if (duplicateIds.length > 0) {
return `Invalid XML: Found duplicate cell IDs (${duplicateIds.slice(0, 3).join(", ")}). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements.`
}
// Second try: line-trimmed match (fallback)
if (!matchFound) {
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
let matches = true;
if (orphanCells.length > 0) {
return `Invalid XML: Found cells without parent attribute (IDs: ${orphanCells.slice(0, 3).join(", ")}). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references.`
}
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = resultLines[i + j].trim();
const searchTrimmed = searchLines[j].trim();
// Validate parent references (now that all IDs are collected)
const badParents = invalidParents.filter((p) => !cellIds.has(p.parent))
if (badParents.length > 0) {
const details = badParents
.slice(0, 3)
.map((p) => `${p.id} (parent: ${p.parent})`)
.join(", ")
return `Invalid XML: Found cells with invalid parent references (${details}). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references.`
}
// Validate edge connections
const invalidConnections: string[] = []
edgesToValidate.forEach((edge) => {
if (edge.source && !cellIds.has(edge.source)) {
invalidConnections.push(`${edge.id} (source: ${edge.source})`)
if (originalTrimmed !== searchTrimmed) {
matches = false;
break;
}
}
if (edge.target && !cellIds.has(edge.target)) {
invalidConnections.push(`${edge.id} (target: ${edge.target})`)
}
})
if (invalidConnections.length > 0) {
return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(", ")}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`
if (matches) {
matchStartLine = i;
matchEndLine = i + searchLines.length;
matchFound = true;
break;
}
}
}
return null
// Third try: substring match as last resort (for single-line XML)
if (!matchFound) {
// Try to find as a substring in the entire content
const searchStr = search.trim();
const resultStr = result;
const index = resultStr.indexOf(searchStr);
if (index !== -1) {
// Found as substring - replace it
result = resultStr.substring(0, index) + replace.trim() + resultStr.substring(index + searchStr.length);
// Re-format after substring replacement
result = formatXML(result);
continue; // Skip the line-based replacement below
}
}
if (!matchFound) {
throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`);
}
// Replace the matched lines
const replaceLines = replace.split('\n');
// Remove trailing empty line if exists
if (replaceLines[replaceLines.length - 1] === '') {
replaceLines.pop();
}
// Perform the replacement
const newResultLines = [
...resultLines.slice(0, matchStartLine),
...replaceLines,
...resultLines.slice(matchEndLine)
];
result = newResultLines.join('\n');
// Update lastProcessedIndex to the position after the replacement
lastProcessedIndex = 0;
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
lastProcessedIndex += newResultLines[i].length + 1;
}
}
return result;
}
export function extractDiagramXML(xml_svg_string: string): string {
try {
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
const svgString = atob(xml_svg_string.slice(26))
const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
const svgElement = svgDoc.querySelector("svg")
try {
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
const svgString = atob(xml_svg_string.slice(26));
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgString, "image/svg+xml");
const svgElement = svgDoc.querySelector('svg');
if (!svgElement) {
throw new Error("No SVG element found in the input string.")
}
// 2. Extract the 'content' attribute
const encodedContent = svgElement.getAttribute("content")
if (!encodedContent) {
throw new Error("SVG element does not have a 'content' attribute.")
}
// 3. Decode HTML entities (using a minimal function)
function decodeHtmlEntities(str: string) {
const textarea = document.createElement("textarea") // Use built-in element
textarea.innerHTML = str
return textarea.value
}
const xmlContent = decodeHtmlEntities(encodedContent)
// 4. Parse the XML content
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
const diagramElement = xmlDoc.querySelector("diagram")
if (!diagramElement) {
throw new Error("No diagram element found")
}
// 5. Extract base64 encoded data
const base64EncodedData = diagramElement.textContent
if (!base64EncodedData) {
throw new Error("No encoded data found in the diagram element")
}
// 6. Decode base64 data
const binaryString = atob(base64EncodedData)
// 7. Convert binary string to Uint8Array
const len = binaryString.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
const decompressedData = pako.inflate(bytes, { windowBits: -15 })
// 9. Convert the decompressed data to a string
const decoder = new TextDecoder("utf-8")
const decodedString = decoder.decode(decompressedData)
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
const urlDecodedString = decodeURIComponent(decodedString)
return urlDecodedString
} catch (error) {
console.error("Error extracting diagram XML:", error)
throw error // Re-throw for caller handling
if (!svgElement) {
throw new Error("No SVG element found in the input string.");
}
// 2. Extract the 'content' attribute
const encodedContent = svgElement.getAttribute('content');
if (!encodedContent) {
throw new Error("SVG element does not have a 'content' attribute.");
}
// 3. Decode HTML entities (using a minimal function)
function decodeHtmlEntities(str: string) {
const textarea = document.createElement('textarea'); // Use built-in element
textarea.innerHTML = str;
return textarea.value;
}
const xmlContent = decodeHtmlEntities(encodedContent);
// 4. Parse the XML content
const xmlDoc = parser.parseFromString(xmlContent, "text/xml");
const diagramElement = xmlDoc.querySelector('diagram');
if (!diagramElement) {
throw new Error("No diagram element found");
}
// 5. Extract base64 encoded data
const base64EncodedData = diagramElement.textContent;
if (!base64EncodedData) {
throw new Error("No encoded data found in the diagram element");
}
// 6. Decode base64 data
const binaryString = atob(base64EncodedData);
// 7. Convert binary string to Uint8Array
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
const decompressedData = pako.inflate(bytes, { windowBits: -15 });
// 9. Convert the decompressed data to a string
const decoder = new TextDecoder('utf-8');
const decodedString = decoder.decode(decompressedData);
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
const urlDecodedString = decodeURIComponent(decodedString);
return urlDecodedString;
} catch (error) {
console.error("Error extracting diagram XML:", error);
throw error; // Re-throw for caller handling
}
}

View File

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

8555
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,23 @@
{
"name": "next-ai-draw-io",
"version": "0.3.0",
"license": "Apache-2.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 6002",
"build": "next build",
"start": "next start --port 6001",
"lint": "biome lint .",
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky"
"lint": "next lint"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.62",
"@ai-sdk/amazon-bedrock": "^3.0.52",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.22",
"@aws-sdk/credential-providers": "^3.943.0",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@vercel/analytics": "^1.5.0",
@@ -40,40 +28,24 @@
"clsx": "^2.1.1",
"jsdom": "^26.0.0",
"lucide-react": "^0.483.0",
"next": "^16.0.7",
"next": "15.2.3",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched",
"biome check --no-errors-on-unmatched"
]
},
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "9.39.1",
"eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

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