Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
1e905b1fc6 chore: add tone and style guidelines to system prompts 2025-12-07 20:10:18 +09:00
60 changed files with 1648 additions and 10807 deletions

View File

@@ -64,27 +64,3 @@ jobs:
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
# Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Login to Amazon ECR
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Push to ECR (triggers App Runner auto-deploy)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REPO_LOWER: ${{ github.repository }}
run: |
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]')
docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest

12
.gitignore vendored
View File

@@ -2,8 +2,6 @@
# dependencies
/node_modules
packages/*/node_modules
packages/*/dist
/.pnp
.pnp.*
.yarn/*
@@ -42,11 +40,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
push-via-ec2.sh
.claude/
.playwright-mcp/
# Cloudflare
.dev.vars
.open-next/
.wrangler/
.env*.local
.claude/settings.local.json
.playwright-mcp/

View File

@@ -22,10 +22,6 @@ COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time argument for self-hosted draw.io URL
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build Next.js application (standalone mode)
RUN npm run build
@@ -54,6 +50,6 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start the application (HOSTNAME override needed for AWS App Runner)
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 exec node server.js"]
# Start the application
CMD ["node", "server.js"]

147
README.md
View File

@@ -4,45 +4,31 @@
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
English | [中文](./README_CN.md) | [日本語](./README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![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](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
[🚀 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.
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## Features
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **AWS Architecture Diagram Support**: Specialized support for generating AWS architecture diagrams
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## Table of Contents
- [Next AI Draw.io ](#next-ai-drawio-)
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
- [Run with Docker (Recommended)](#run-with-docker-recommended)
- [Installation](#installation)
- [Deployment](#deployment)
- [Multi-Provider Support](#multi-provider-support)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Support \& Contact](#support--contact)
- [Star History](#star-history)
## Examples
## **Examples**
Here are some example prompts and their generated diagrams:
@@ -82,59 +68,38 @@ Here are some example prompts and their generated diagrams:
</table>
</div>
## Features
## How It Works
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
The application uses the following technologies:
## MCP Server (Preview)
- **Next.js**: For the frontend framework and routing
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
- **react-drawio**: For diagram representation and manipulation
> **Preview Feature**: This feature is experimental and may not stable.
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
## Multi-Provider Support
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
- AWS Bedrock (default)
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
### Claude Code CLI
All providers except AWS Bedrock and OpenRouter support custom endpoints.
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
Then ask Claude to create diagrams:
> "Create a flowchart showing user authentication with login, MFA, and session management"
**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.
The diagram appears in your browser in real-time!
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
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
### Try it Online
No installation needed! Try the app directly on our demo site:
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> Note: Due to high traffic, the demo site currently uses minimax-m2. For best results, we recommend self-hosting with Claude Sonnet 4.5 or Claude Opus 4.5.
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
### Run with Docker (Recommended)
If you just want to run it locally, the best way is to use Docker.
@@ -151,7 +116,7 @@ docker run -d -p 3000:3000 \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Or use an env file:
Or use an env file (create one from `env.example`):
```bash
cp env.example .env
@@ -163,8 +128,6 @@ 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.
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./docs/offline-deployment.md) for configuration options.
### Installation
1. Clone the repository:
@@ -219,38 +182,6 @@ Or you can deploy by this button.
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
## Multi-Provider Support
- AWS Bedrock (default)
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
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-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
## How It Works
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
- **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.
## Project Structure
```
@@ -270,6 +201,14 @@ lib/ # Utility functions and helpers
public/ # Static assets including example images
```
## TODOs
- [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.
## Support & Contact
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!

View File

@@ -4,16 +4,14 @@
**AI驱动的图表创建工具 - 对话、绘制、可视化**
[English](../README.md) | 中文 | [日本語](./README_JA.md)
[English](./README.md) | 中文 | [日本語](./README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![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](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
[🚀 在线演示](https://next-ai-drawio.jiang.jp/)
</div>
@@ -21,24 +19,16 @@
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 目录
- [Next AI Draw.io](#next-ai-drawio)
- [目录](#目录)
- [示例](#示例)
- [功能特性](#功能特性)
- [MCP服务器预览](#mcp服务器预览)
- [快速开始](#快速开始)
- [在线试用](#在线试用)
- [使用Docker运行推荐](#使用docker运行推荐)
- [安装](#安装)
- [部署](#部署)
- [多提供商支持](#多提供商支持)
- [工作原理](#工作原理)
- [项目结构](#项目结构)
- [支持与联系](#支持与联系)
- [Star历史](#star历史)
## 功能特性
## 示例
- **LLM驱动的图表创建**利用大语言模型通过自然语言命令直接创建和操作draw.io图表
- **基于图像的图表复制**上传现有图表或图像让AI自动复制和增强
- **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**与AI实时对话来完善您的图表
- **AWS架构图支持**专门支持生成AWS架构图
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## **示例**
以下是一些示例提示词及其生成的图表:
@@ -48,89 +38,68 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<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" />
<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" />
<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" />
<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" />
<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" />
<img src="./public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td>
</tr>
</table>
</div>
## 功能特性
## 工作原理
- **LLM驱动的图表创建**利用大语言模型通过自然语言命令直接创建和操作draw.io图表
- **基于图像的图表复制**上传现有图表或图像让AI自动复制和增强
- **PDF和文本文件上传**上传PDF文档和文本文件提取内容并从现有文档生成图表
- **AI推理过程显示**查看支持模型的AI思考过程OpenAI o1/o3、Gemini、Claude等
- **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**与AI实时对话来完善您的图表
- **云架构图支持**专门支持生成云架构图AWS、GCP、Azure
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
本应用使用以下技术:
## MCP服务器预览
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**`ai` + `@ai-sdk/*`用于流式AI响应和多提供商支持
- **react-drawio**:用于图表表示和操作
> **预览功能**:此功能为实验性功能,可能会有变化
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML
通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
## 多提供商支持
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
- AWS Bedrock默认
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
### Claude Code CLI
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
然后让Claude创建图表
> "创建一个展示用户认证流程的流程图包含登录、MFA和会话管理"
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
图表会实时显示在浏览器中!
详情请参阅[MCP服务器README](../packages/mcp-server/README.md)了解VS Code、Cursor等客户端配置。
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择。
## 快速开始
### 在线试用
无需安装!直接在我们的演示站点试用:
[![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> 注意:由于访问量较大,演示站点目前使用 minimax-m2 模型。如需获得最佳效果,建议使用 Claude Sonnet 4.5 或 Claude Opus 4.5 自行部署。
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
### 使用Docker运行推荐
如果您只想在本地运行最好的方式是使用Docker。
@@ -147,20 +116,10 @@ docker run -d -p 3000:3000 \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
或者使用 env 文件:
```bash
cp env.example .env
# 编辑 .env 填写您的配置
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
请根据您首选的AI提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
> **离线部署:** 如果 `embed.diagrams.net` 被屏蔽,请参阅 [离线部署指南](./offline-deployment.md) 了解配置选项。
### 安装
1. 克隆仓库:
@@ -174,6 +133,8 @@ cd next-ai-draw-io
```bash
npm install
# 或
yarn install
```
3. 配置您的AI提供商
@@ -194,7 +155,7 @@ cp env.example .env.local
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
4. 运行开发服务器:
@@ -215,38 +176,6 @@ npm run dev
请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
## 多提供商支持
- AWS Bedrock默认
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./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架构图这是最佳选择。
## 工作原理
本应用使用以下技术:
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**`ai` + `@ai-sdk/*`用于流式AI响应和多提供商支持
- **react-drawio**:用于图表表示和操作
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 项目结构
```
@@ -266,6 +195,14 @@ lib/ # 工具函数和辅助程序
public/ # 静态资源包括示例图片
```
## 待办事项
- [x] 允许LLM修改XML而不是每次从头生成
- [x] 提高形状流式更新的流畅度
- [x] 添加多AI提供商支持OpenAI, Anthropic, Google, Azure, Ollama
- [x] 解决超过60秒的会话生成失败的bug
- [ ] 在UI上添加API配置
## 支持与联系
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!

View File

@@ -4,16 +4,14 @@
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
[English](../README.md) | [中文](./README_CN.md) | 日本語
[English](./README.md) | [中文](./README_CN.md) | 日本語
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![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](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
[🚀 ライブデモ](https://next-ai-drawio.jiang.jp/)
</div>
@@ -21,24 +19,16 @@ AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケ
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 目次
- [Next AI Draw.io](#next-ai-drawio)
- [目次](#目次)
- [](#例)
- [機能](#機能)
- [MCPサーバープレビュー](#mcpサーバープレビュー)
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す)
- [Dockerで実行推奨](#dockerで実行推奨)
- [インストール](#インストール)
- [デプロイ](#デプロイ)
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み)
- [プロジェクト構造](#プロジェクト構造)
- [サポート&お問い合わせ](#サポートお問い合わせ)
- [スター履歴](#スター履歴)
## 機能
## 例
- **LLM搭載のダイアグラム作成**大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
- **画像ベースのダイアグラム複製**既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **AWSアーキテクチャダイアグラムサポート**AWSアーキテクチャダイアグラムの生成を専門的にサポート
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## **例**
以下はいくつかのプロンプト例と生成されたダイアグラムです:
@@ -48,89 +38,68 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<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" />
<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" />
<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" />
<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" />
<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" />
<img src="./public/cat_demo.svg" alt="猫の絵" width="240" />
</td>
</tr>
</table>
</div>
## 機能
## 仕組み
- **LLM搭載のダイアグラム作成**大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
- **画像ベースのダイアグラム複製**既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **PDFとテキストファイルのアップロード**PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
- **AI推論プロセス表示**サポートされているモデルOpenAI o1/o3、Gemini、ClaudeなどのAIの思考プロセスを表示
- **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
本アプリケーションは以下の技術を使用しています:
## MCPサーバープレビュー
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**`ai` + `@ai-sdk/*`ストリーミングAIレスポンスとマルチプロバイダーサポート
- **react-drawio**:ダイアグラムの表現と操作
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
## マルチプロバイダーサポート
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
- AWS Bedrockデフォルト
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
### Claude Code CLI
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
Claudeにダイアグラムの作成を依頼
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
ダイアグラムがリアルタイムでブラウザに表示されます!
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧くださいVS Code、Cursorなどのクライアント設定も含む
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
## はじめに
### オンラインで試す
インストール不要!デモサイトで直接お試しください:
[![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> 注意:アクセス数が多いため、デモサイトでは現在 minimax-m2 モデルを使用しています。最高の結果を得るには、Claude Sonnet 4.5 または Claude Opus 4.5 でのセルフホスティングをお勧めします。
> **自分のAPIキーを使用**自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
### Dockerで実行推奨
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
@@ -147,20 +116,10 @@ docker run -d -p 3000:3000 \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
または env ファイルを使用:
```bash
cp env.example .env
# .env を編集して設定を入力
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。
環境変数はお好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
> **オフラインデプロイ:** `embed.diagrams.net` がブロックされている場合は、[オフラインデプロイガイド](./offline-deployment.md) で設定オプションをご確認ください。
### インストール
1. リポジトリをクローン:
@@ -174,6 +133,8 @@ cd next-ai-draw-io
```bash
npm install
# または
yarn install
```
3. AIプロバイダーを設定
@@ -194,7 +155,7 @@ cp env.example .env.local
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
4. 開発サーバーを起動:
@@ -215,38 +176,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
## マルチプロバイダーサポート
- AWS Bedrockデフォルト
- OpenAI
- Anthropic
- Google AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./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アーキテクチャダイアグラムを作成したい場合は最適な選択です。
## 仕組み
本アプリケーションは以下の技術を使用しています:
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**`ai` + `@ai-sdk/*`ストリーミングAIレスポンスとマルチプロバイダーサポート
- **react-drawio**:ダイアグラムの表現と操作
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
## プロジェクト構造
```
@@ -266,6 +195,14 @@ lib/ # ユーティリティ関数とヘルパー
public/ # サンプル画像を含む静的アセット
```
## TODO
- [x] LLMが毎回ゼロから生成する代わりにXMLを修正できるようにする
- [x] シェイプストリーミング更新の滑らかさを改善
- [x] 複数のAIプロバイダーサポートを追加OpenAI, Anthropic, Google, Azure, Ollama
- [x] 60秒以上のセッションで生成が失敗するバグを解決
- [ ] UIにAPI設定を追加
## サポート&お問い合わせ
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!

22
amplify.yml Normal file
View File

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

View File

@@ -10,18 +10,7 @@ export const metadata: Metadata = {
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
}
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutCN() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -96,124 +85,12 @@ export default function AboutCN() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
()
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
AI
(TPS/TPM)
</p>
<p>
使 Claude {" "}
<span className="font-semibold text-amber-700">
minimax-m2
</span>
</p>
<p>
<span className="font-semibold text-amber-700">
</span>
API
</p>
</div>
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Token
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
</div>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */}
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
使 API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
使 API Key
Provider API Key
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
Key
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
()
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
AI API
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
GitHub Live Demo
Logo
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
</a>
</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">

View File

@@ -17,18 +17,7 @@ export const metadata: Metadata = {
],
}
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutJA() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -104,121 +93,13 @@ export default function AboutJA() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
AI API (TPS/TPM)
</p>
<p>
Claude {" "}
<span className="font-semibold text-amber-700">
minimax-m2
</span>{" "}
</p>
<p>
<span className="font-semibold text-amber-700">
</span>
API
</p>
</div>
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
使
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
1
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
</div>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */}
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
APIキーを使用
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
APIキーを使用することでAPIキーを設定してください
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
AI
API
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
GitHub
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
</a>
</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">

View File

@@ -17,18 +17,7 @@ export const metadata: Metadata = {
],
}
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function About() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -104,134 +93,15 @@ export default function About() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
Model Change & Usage Limits{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
(Or: Why My Wallet is Crying)
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
The response to this project has been
incredibleyou all love making diagrams!
However, this enthusiasm means we are
frequently hitting the AI API rate limits
(TPS/TPM). When this happens, the system
pauses, leading to failed requests.
</p>
<p>
Due to the high usage, I have changed the
model from Claude to{" "}
<span className="font-semibold text-amber-700">
minimax-m2
</span>
, which is more cost-effective.
</p>
<p>
As an{" "}
<span className="font-semibold text-amber-700">
indie developer
</span>
, I am currently footing the entire API
bill. To keep the lights on and ensure the
service remains available to everyone
without sending me into debt, I have also
implemented the following temporary caps:
</p>
</div>
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Token Usage
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
<span className="text-sm font-normal text-gray-600">
/min
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/day
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Daily Requests
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
requests
</div>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */}
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
Bring Your Own API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
You can use your own API key to bypass these
limits. Click the Settings icon in the chat
panel to configure your provider and API
key.
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
Your key is stored locally in your browser
and is never stored on the server.
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
Call for Sponsorship
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
Scaling the backend is the only way to
remove these limits. I am actively seeking
sponsorship from AI API providers or Cloud
Platforms.
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
In return for support (credits or funding),
I will prominently feature your company as a
platform sponsor on both the GitHub
repository and the live demo site.
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Contact Me
</a>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800">
This app is designed to run on Claude Opus 4.5 for
best performance. However, due to
higher-than-expected traffic, running the top-tier
model has become cost-prohibitive. To avoid service
interruptions and manage costs, I have switched the
backend to Claude Haiku 4.5.
</p>
</div>
<p className="text-gray-700">

View File

@@ -1,16 +1,12 @@
import {
APICallError,
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError,
stepCountIs,
streamText,
} from "ai"
import { jsonrepair } from "jsonrepair"
import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { getAIModel } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
getTelemetryConfig,
@@ -20,7 +16,7 @@ import {
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 120
export const maxDuration = 60
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -68,44 +64,32 @@ function isMinimalDiagram(xml: string): boolean {
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] {
// Helper function to fix tool call inputs for Bedrock API
// Bedrock requires toolUse.input to be a JSON object, not a string
function fixToolCallInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
const fixedContent = msg.content.map((part: any) => {
if (part.type === "tool-call") {
if (typeof part.input === "string") {
try {
const parsed = JSON.parse(part.input)
return { ...part, input: parsed }
} catch {
// If parsing fails, wrap the string in an object
return { ...part, input: { rawInput: part.input } }
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
// Input is already an object, but verify it's not null/undefined
if (part.input === null || part.input === undefined) {
return { ...part, input: {} }
}
}
return part
})
return { ...msg, content: fixedContent }
})
}
@@ -158,7 +142,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
}
}
const { messages, xml, previousXml, sessionId } = await req.json()
const { messages, xml, sessionId } = await req.json()
// Get user IP for Langfuse tracking
const forwardedFor = req.headers.get("x-forwarded-for")
@@ -171,9 +155,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined
// Extract user input text for Langfuse trace
const lastMessage = messages[messages.length - 1]
const currentMessage = messages[messages.length - 1]
const userInputText =
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user
setTraceInput({
@@ -193,42 +177,58 @@ async function handleChatRequest(req: Request): Promise<Response> {
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
// DEBUG: Log cache check conditions
console.log("[Cache DEBUG] messages.length:", messages.length)
console.log("[Cache DEBUG] isFirstMessage:", isFirstMessage)
console.log("[Cache DEBUG] xml length:", xml?.length || 0)
console.log("[Cache DEBUG] xml preview:", xml?.substring(0, 200))
console.log("[Cache DEBUG] isEmptyDiagram:", isEmptyDiagram)
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")
console.log("[Cache DEBUG] textPart?.text:", textPart?.text)
console.log("[Cache DEBUG] hasFilePart:", !!filePart)
const cached = findCachedResponse(textPart?.text || "", !!filePart)
console.log("[Cache DEBUG] cached found:", !!cached)
if (cached) {
console.log(
"[Cache] Returning cached response for:",
textPart?.text,
)
return createCachedStreamResponse(cached.xml)
} else {
console.log("[Cache DEBUG] No cache match - checking why...")
console.log(
"[Cache DEBUG] Exact promptText:",
JSON.stringify(textPart?.text),
)
}
} else {
console.log("[Cache DEBUG] Skipping cache check - conditions not met")
if (!isFirstMessage)
console.log("[Cache DEBUG] Reason: not first message")
if (!isEmptyDiagram)
console.log("[Cache DEBUG] Reason: diagram not empty")
}
// === CACHE CHECK END ===
// Read client AI provider overrides from headers
const clientOverrides = {
provider: req.headers.get("x-ai-provider"),
baseUrl: req.headers.get("x-ai-base-url"),
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
}
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
// Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides)
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId)
console.log(
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
)
// 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, minimalStyle)
const systemMessage = getSystemPrompt(modelId)
const lastMessage = messages[messages.length - 1]
// Extract text from the last message parts
const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
// Extract file parts (images) from the last message
const fileParts =
@@ -237,114 +237,44 @@ async function handleChatRequest(req: Request): Promise<Response> {
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
"""md
${userInputText}
${lastMessageText}
"""`
// Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// Debug: log raw messages to see what's coming in
console.log(
"[DEBUG] Raw UI messages:",
JSON.stringify(
messages.map((m: any, i: number) => ({
index: i,
role: m.role,
partsCount: m.parts?.length,
parts: m.parts?.map((p: any) => ({
type: p.type,
toolName: p.toolName,
toolCallId: p.toolCallId,
state: p.state,
inputType: p.input ? typeof p.input : undefined,
input: p.input,
})),
})),
null,
2,
),
)
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
const enableHistoryReplace =
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
const placeholderMessages = enableHistoryReplace
? replaceHistoricalToolInputs(modelMessages)
: modelMessages
// Fix tool call inputs for Bedrock API (requires JSON objects, not strings)
const fixedMessages = fixToolCallInputs(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 = placeholderMessages.filter(
let enhancedMessages = fixedMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
enhancedMessages = enhancedMessages
.map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const filteredContent = msg.content.filter((part: any) => {
if (part.type === "tool-call") {
// Check if input is a valid object (not null, undefined, or empty)
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
console.warn(
`[route.ts] Filtering out tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
return false
}
}
return true
})
return { ...msg, content: filteredContent }
})
.filter((msg: any) => msg.content && msg.content.length > 0)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
@@ -373,7 +303,7 @@ ${userInputText}
// 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 (shouldCache && enhancedMessages.length >= 2) {
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") {
@@ -398,21 +328,17 @@ ${userInputText}
{
role: "system" as const,
content: systemMessage,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
},
// Cache breakpoint 2: Previous and Current diagram XML context
// Cache breakpoint 2: Current diagram XML context
{
role: "system" as const,
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
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" } },
},
},
]
@@ -420,73 +346,9 @@ ${userInputText}
const result = streamText({
model,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => {
// DEBUG: Log what we're trying to repair
console.log(`[repairToolCall] Tool: ${toolCall.toolName}`)
console.log(
`[repairToolCall] Error: ${error.name} - ${error.message}`,
)
console.log(`[repairToolCall] Input type: ${typeof toolCall.input}`)
console.log(`[repairToolCall] Input value:`, toolCall.input)
// Only attempt repair for invalid tool input (broken JSON from truncation)
if (
error instanceof InvalidToolInputError ||
error.name === "AI_InvalidToolInputError"
) {
try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
let inputToRepair = toolCall.input
if (typeof inputToRepair === "string") {
// Fix `:=` instead of `: ` (LLM sometimes generates this)
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
}
// Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair)
console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
)
return { ...toolCall, input: repairedInput }
} catch (repairError) {
console.warn(
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError,
)
// Return a placeholder input to avoid API errors in multi-step
// The tool will fail gracefully on client side
if (toolCall.toolName === "edit_diagram") {
return {
...toolCall,
input: {
operations: [],
_error: "JSON repair failed - no operations to apply",
},
}
}
if (toolCall.toolName === "display_diagram") {
return {
...toolCall,
input: {
xml: "",
_error: "JSON repair failed - empty diagram",
},
}
}
return null
}
}
// Don't attempt to repair other errors (like NoSuchToolError)
return null
},
messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(providerOptions && { providerOptions }),
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
@@ -495,8 +357,40 @@ ${userInputText}
userId,
}),
}),
onFinish: ({ text, usage }) => {
// Repair malformed tool calls (model sometimes generates invalid JSON with unescaped quotes)
experimental_repairToolCall: async ({ toolCall }) => {
// The toolCall.input contains the raw JSON string that failed to parse
const rawJson =
typeof toolCall.input === "string" ? toolCall.input : null
if (rawJson) {
try {
// Fix unescaped quotes: x="520" should be x=\"520\"
const fixed = rawJson.replace(
/([a-zA-Z])="(\d+)"/g,
'$1=\\"$2\\"',
)
const parsed = JSON.parse(fixed)
return {
type: "tool-call" as const,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
input: JSON.stringify(parsed),
}
} catch {
// Repair failed, return null
}
}
return null
},
onFinish: ({ text, usage, providerMetadata }) => {
console.log(
"[Cache] Full providerMetadata:",
JSON.stringify(providerMetadata, null, 2),
)
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
setTraceOutput(text, {
promptTokens: usage?.inputTokens,
completionTokens: usage?.outputTokens,
@@ -505,32 +399,36 @@ ${userInputText}
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
6. Escape special chars in values: &lt; &gt; &amp; &quot;
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 (generate ONLY this - no wrapper tags):
<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>
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**.
@@ -543,56 +441,32 @@ Notes:
}),
},
edit_diagram: {
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell by its id. Only cell_id is needed.
For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
inputSchema: z.object({
operations: z
edits: z
.array(
z.object({
type: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z
search: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
"EXACT lines copied from current XML (preserve attribute order!)",
),
new_xml: z
replace: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
.describe("Replacement lines"),
}),
)
.describe("Array of operations to apply"),
}),
},
append_diagram: {
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
inputSchema: z.object({
xml: z
.string()
.describe(
"Continuation XML fragment to append (NO wrapper tags)",
"Array of search/replace pairs to apply sequentially",
),
}),
},
@@ -602,92 +476,7 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
}),
})
return result.toUIMessageStreamResponse({
sendReasoning: true,
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
if (!usage) {
console.warn(
"[messageMetadata] No usage data in finish part",
)
return undefined
}
// Total input = non-cached + cached (these are separate counts)
// Note: cacheWriteInputTokens is not available on finish part
const totalInputTokens =
(usage.inputTokens ?? 0) + (usage.cachedInputTokens ?? 0)
return {
inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0,
finishReason: (part as any).finishReason,
}
}
return undefined
},
})
}
// Helper to categorize errors and return appropriate response
function handleError(error: unknown): Response {
console.error("Error in chat route:", error)
const isDev = process.env.NODE_ENV === "development"
// Check for specific AI SDK error types
if (APICallError.isInstance(error)) {
return Response.json(
{
error: error.message,
...(isDev && {
details: error.responseBody,
stack: error.stack,
}),
},
{ status: error.statusCode || 500 },
)
}
if (LoadAPIKeyError.isInstance(error)) {
return Response.json(
{
error: "Authentication failed. Please check your API key.",
...(isDev && {
stack: error.stack,
}),
},
{ status: 401 },
)
}
// Fallback for other errors with safety filter
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
const status = (error as any)?.statusCode || (error as any)?.status || 500
// Prevent leaking API keys, tokens, or other sensitive data
const lowerMessage = message.toLowerCase()
const safeMessage =
lowerMessage.includes("key") ||
lowerMessage.includes("token") ||
lowerMessage.includes("sig") ||
lowerMessage.includes("signature") ||
lowerMessage.includes("secret") ||
lowerMessage.includes("password") ||
lowerMessage.includes("credential")
? "Authentication failed. Please check your credentials."
: message
return Response.json(
{
error: safeMessage,
...(isDev && {
details: message,
stack: error instanceof Error ? error.stack : undefined,
}),
},
{ status },
)
return result.toUIMessageStreamResponse()
}
// Wrap handler with error handling
@@ -695,7 +484,11 @@ async function safeHandler(req: Request): Promise<Response> {
try {
return await handleChatRequest(req)
} catch (error) {
return handleError(error)
console.error("Error in chat route:", error)
return Response.json(
{ error: "Internal server error" },
{ status: 500 },
)
}
}

View File

@@ -81,15 +81,16 @@ Contains the actual diagram data.
## Root Cell Container: `<root>`
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
Contains all the cells in the diagram.
**Internal structure (auto-generated):**
**Example:**
```xml
<root>
<mxCell id="0"/> <!-- Auto-added -->
<mxCell id="1" parent="0"/> <!-- Auto-added -->
<!-- Your mxCell elements go here (start from id="2") -->
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Other cells go here -->
</root>
```
@@ -202,15 +203,15 @@ Draw.io files contain two special cells that are always present:
1. **Root Cell** (id = "0"): The parent of all cells
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
## Tips for Creating Draw.io XML
## Tips for Manually Creating Draw.io XML
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
1. Start with the basic structure (`mxfile`, `diagram`, `mxGraphModel`, `root`)
2. Always include the two special cells (id = "0" and id = "1")
3. Assign unique and sequential IDs to all cells
4. Define parent relationships correctly (use parent="1" for top-level shapes)
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 siblings. NEVER nest mxCell inside another mxCell.**
7. **CRITICAL: All mxCell elements must be DIRECT children of `<root>`. NEVER nest mxCell inside another mxCell.**
## Common Patterns

View File

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

View File

@@ -1,4 +1,5 @@
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"
@@ -105,7 +106,7 @@ export default function RootLayout({
}
return (
<html lang="en" suppressHydrationWarning>
<html lang="en">
<head>
<script
type="application/ld+json"
@@ -116,6 +117,7 @@ export default function RootLayout({
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DiagramProvider>{children}</DiagramProvider>
<Analytics />
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />

View File

@@ -1,27 +0,0 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next AI Draw.io',
short_name: 'AIDraw.io',
description: 'Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.',
start_url: '/',
display: 'standalone',
background_color: '#f9fafb',
theme_color: '#171d26',
icons: [
{
src: '/favicon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/favicon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
}
}

View File

@@ -11,74 +11,33 @@ import {
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const {
drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
} = useDiagram()
const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
// Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
}
setIsThemeLoaded(true)
}, [])
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
// Load preferences from localStorage after mount
// Load close protection setting from localStorage after mount
useEffect(() => {
const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
}
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
setDarkMode(prefersDark)
document.documentElement.classList.toggle("dark", prefersDark)
}
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
// Default to false since auto-save handles persistence
if (saved === "true") {
setCloseProtection(true)
}
setIsLoaded(true)
}, [])
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const handleDarkModeChange = async () => {
await saveDiagramToStorage()
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
resetDrawioReady()
}
const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}
// Check mobile
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
@@ -102,7 +61,6 @@ export default function Home() {
}
}
// Keyboard shortcut for toggling chat panel
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
@@ -116,6 +74,7 @@ export default function Home() {
}, [])
// Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures
useEffect(() => {
if (!closeProtection) return
@@ -132,41 +91,34 @@ export default function Home() {
return (
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
id="main-panel-group"
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
{/* Draw.io Canvas */}
<ResizablePanel
id="drawio-panel"
defaultSize={isMobile ? 50 : 67}
minSize={20}
>
<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">
{isLoaded ? (
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
{isThemeLoaded ? (
<DrawIoEmbed
key={`${drawioUi}-${darkMode}`}
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={onDrawioLoad}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
dark: darkMode,
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-background">
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
@@ -178,7 +130,6 @@ export default function Home() {
{/* Chat Panel */}
<ResizablePanel
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
minSize={isMobile ? 20 : 15}
@@ -193,9 +144,12 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
onToggleDrawioUi={() => {
const newTheme =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newTheme)
setDrawioUi(newTheme)
}}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>

View File

@@ -1,186 +0,0 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
type ReasoningContextValue = {
isStreaming: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
duration: number | undefined
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
export const useReasoning = () => {
const context = useContext(ReasoningContext)
if (!context) {
throw new Error("Reasoning components must be used within Reasoning")
}
return context
}
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
duration?: number
}
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
})
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null)
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now())
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
}
}, [isStreaming, startTime, setDuration])
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen)
}
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
},
)
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning()
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</>
)}
</CollapsibleTrigger>
)
},
)
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string
}
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
<div className="whitespace-pre-wrap">{children}</div>
</CollapsibleContent>
),
)
Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger"
ReasoningContent.displayName = "ReasoningContent"

View File

@@ -1,64 +0,0 @@
"use client"
import { motion } from "motion/react"
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react"
import { cn } from "@/lib/utils"
export type TextShimmerProps = {
children: string
as?: ElementType
className?: string
duration?: number
spread?: number
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
)
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
)
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
)
}
export const Shimmer = memo(ShimmerComponent)

View File

@@ -1,59 +1,28 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
isNew?: boolean
}
function ExampleCard({
icon,
title,
description,
onClick,
isNew,
}: ExampleCardProps) {
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
return (
<button
onClick={onClick}
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
isNew
? "border-primary/40 ring-1 ring-primary/20"
: "border-border/60"
}`}
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 flex items-center justify-center shrink-0 transition-colors ${
isNew
? "bg-primary/20 group-hover:bg-primary/25"
: "bg-primary/10 group-hover:bg-primary/15"
}`}
>
<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">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
NEW
</span>
)}
</div>
<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>
@@ -98,50 +67,8 @@ export default function ExamplePanel({
}
}
const handlePdfExample = async () => {
setInput("Summarize this paper as a diagram")
try {
const response = await fetch("/chain-of-thought.txt")
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",
})
setFiles([file])
} catch (error) {
console.error("Error loading text file:", error)
}
}
return (
<div className="py-6 px-2 animate-fade-in">
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
MCP Server
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
PREVIEW
</span>
</div>
<p className="text-xs text-muted-foreground">
Use in Claude Desktop, VS Code & Cursor
</p>
</div>
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
@@ -160,14 +87,6 @@ export default function ExamplePanel({
</p>
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="Paper to Diagram"
description="Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more"
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram"

View File

@@ -4,7 +4,9 @@ import {
Download,
History,
Image as ImageIcon,
LayoutGrid,
Loader2,
PenTool,
Send,
Trash2,
} from "lucide-react"
@@ -17,24 +19,21 @@ 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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { useDiagram } from "@/contexts/diagram-context"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
function isValidFileType(file: File): boolean {
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
@@ -74,16 +73,9 @@ function validateFiles(
errors.push(`Only ${availableSlots} more file(s) allowed`)
break
}
if (!isValidFileType(file)) {
errors.push(`"${file.name}" is not a supported file type`)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
if (file.size > MAX_FILE_SIZE) {
errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds ${maxSizeMB}MB)`,
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
)
} else {
validFiles.push(file)
@@ -127,16 +119,12 @@ interface ChatInputProps {
onClearChat: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void
}
export function ChatInput({
@@ -147,13 +135,12 @@ export function ChatInput({
onClearChat,
files = [],
onFileChange = () => {},
pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -161,6 +148,7 @@ export function ChatInput({
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
@@ -272,14 +260,11 @@ export function ChatInput({
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/"),
)
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
)
const { validFiles, errors } = validateFiles(imageFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
@@ -309,7 +294,6 @@ export function ChatInput({
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
/>
</div>
)}
@@ -322,7 +306,7 @@ export function ChatInput({
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or upload a file..."
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"
@@ -354,31 +338,59 @@ export function ChatInput({
onToggleHistory={onToggleHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
className="scale-75"
/>
<label
htmlFor="minimal-style"
className={`text-xs cursor-pointer select-none ${
minimalStyle
? "text-primary font-medium"
: "text-muted-foreground"
}`}
<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)
}
>
{minimalStyle ? "Minimal" : "Styled"}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Use minimal for faster generation (no colors)
</TooltipContent>
</Tooltip>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat()
onToggleDrawioUi()
setShowThemeWarning(false)
}}
>
Switch Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Right actions */}
@@ -424,7 +436,7 @@ export function ChatInput({
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload file (image, PDF, text)"
tooltipContent="Upload image"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
@@ -435,7 +447,7 @@ export function ChatInput({
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
accept="image/*"
multiple
disabled={isDisabled}
/>

View File

@@ -8,56 +8,29 @@ import {
ChevronUp,
Copy,
Cpu,
FileCode,
FileText,
Minus,
Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import type { MutableRefObject } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
applyDiagramOperations,
convertToLegalXml,
isMxCellXmlComplete,
replaceNodes,
validateAndFixXml,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface DiagramOperation {
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
// Helper to extract complete operations from streaming input
function getCompleteOperations(
operations: DiagramOperation[] | undefined,
): DiagramOperation[] {
if (!operations || !Array.isArray(operations)) return []
return operations.filter(
(op) =>
op &&
typeof op.type === "string" &&
["update", "add", "delete"].includes(op.type) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.type === "delete" || typeof op.new_xml === "string"),
)
interface EditPair {
search: string
replace: string
}
// Tool part interface for type safety
@@ -65,44 +38,49 @@ interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
{edits.map((edit, index) => (
<div
key={`${op.type}-${op.cell_id}-${index}`}
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-[10px] font-medium uppercase tracking-wide ${
op.type === "delete"
? "text-red-600"
: op.type === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.type}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
<span className="text-xs font-medium text-muted-foreground">
Change {index + 1}
</span>
</div>
{op.new_xml && (
<div className="divide-y divide-border/30">
{/* Search (old) */}
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
<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>
@@ -111,59 +89,6 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files)
interface TextSection {
type: "text" | "file"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text"
}
function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
// Match [PDF: filename] or [File: filename] patterns
const filePattern =
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
let lastIndex = 0
let match
while ((match = filePattern.exec(text)) !== null) {
// Add text before this file section
const beforeText = text.slice(lastIndex, match.index).trim()
if (beforeText) {
sections.push({ type: "text", content: beforeText })
}
// Add file section
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
const filename = match[2].trim()
const fileContent = match[3].trim()
sections.push({
type: "file",
content: fileContent,
filename,
charCount: fileContent.length,
fileType,
})
lastIndex = match.index + match[0].length
}
// Add remaining text after last file section
const remainingText = text.slice(lastIndex).trim()
if (remainingText) {
sections.push({ type: "text", content: remainingText })
}
// If no file sections found, return original text
if (sections.length === 0) {
sections.push({ type: "text", content: text })
}
return sections
}
const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return ""
return message.parts
@@ -172,57 +97,27 @@ const getMessageTextContent = (message: UIMessage): string => {
.join("\n")
}
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
// Strip out [PDF: ...] and [File: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
}
interface ChatMessageDisplayProps {
messages: UIMessage[]
setInput: (input: string) => void
setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
sessionId?: string
onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
}
export function ChatMessageDisplay({
messages,
setInput,
setFiles,
processedToolCallsRef,
editDiagramOriginalXmlRef,
sessionId,
onRegenerate,
onEditMessage,
status = "idle",
}: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const processedToolCalls = useRef<Set<string>>(new Set())
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
@@ -236,44 +131,16 @@ export function ChatMessageDisplay({
)
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
const [editText, setEditText] = useState<string>("")
// Track which PDF sections are expanded (key: messageId-sectionIndex)
const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean>
>({})
const copyMessageToClipboard = async (messageId: string, text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
try {
textarea.select()
const success = document.execCommand("copy")
if (!success) {
throw new Error("Copy command failed")
}
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (fallbackErr) {
console.error("Failed to copy message:", fallbackErr)
toast.error(
"Failed to copy message. Please copy manually or check clipboard permissions.",
)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
} finally {
document.body.removeChild(textarea)
}
console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
@@ -301,88 +168,30 @@ export function ChatMessageDisplay({
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error("Failed to record your feedback. Please try again.")
// Revert optimistic UI update
setFeedback((prev) => {
const next = { ...prev }
delete next[messageId]
return next
})
console.warn("Failed to log feedback:", error)
}
}
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
(xml: string) => {
const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
const testDoc = parser.parseFromString(convertedXml, "text/xml")
const parseError = testDoc.querySelector("parsererror")
// If chartXML is empty, use the converted XML directly
const replacedXML = chartXML
? replaceNodes(chartXML, convertedXml)
: convertedXml
if (parseError) {
// Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) {
// Only log as error and show toast if this is the final XML
console.error(
"[ChatMessageDisplay] Malformed XML detected in final output",
)
toast.error(
"AI generated invalid diagram XML. Please try regenerating.",
)
}
return // Skip this update
}
try {
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
const baseXML =
chartXML ||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
// Validate and auto-fix the XML
const validation = validateAndFixXml(replacedXML)
if (validation.valid) {
previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML
if (validation.fixes.length > 0) {
console.log(
"[ChatMessageDisplay] Auto-fixed XML issues:",
validation.fixes,
)
}
// Skip validation in loadDiagram since we already validated above
onDisplayChart(xmlToLoad, true)
} else {
console.error(
"[ChatMessageDisplay] XML validation failed:",
validation.error,
)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(
"Diagram validation failed. Please try regenerating.",
)
}
}
} catch (error) {
console.error(
"[ChatMessageDisplay] Error processing XML:",
error,
const validationError = validateMxCellStructure(replacedXML)
if (!validationError) {
previousXML.current = convertedXml
// Skip validation in loadDiagram since we already validated above
onDisplayChart(replacedXML, true)
} else {
console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(
"Failed to process diagram. Please try regenerating.",
)
}
}
}
},
@@ -402,12 +211,7 @@ export function ChatMessageDisplay({
}, [editingMessageId])
useEffect(() => {
// Only process the last message for streaming performance
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
messagesToProcess.forEach((message) => {
messages.forEach((message) => {
if (message.parts) {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
@@ -426,194 +230,24 @@ export function ChatMessageDisplay({
input?.xml
) {
const xml = input.xml as string
// Skip if XML hasn't changed since last processing
const lastXml =
lastProcessedXmlRef.current.get(toolCallId)
if (lastXml === xml) {
return // Skip redundant processing
}
if (
state === "input-streaming" ||
state === "input-available"
) {
// Debounce streaming updates - queue the XML and process after delay
pendingXmlRef.current = xml
if (!debounceTimeoutRef.current) {
// No pending timeout - set one up
debounceTimeoutRef.current = setTimeout(
() => {
const pendingXml =
pendingXmlRef.current
debounceTimeoutRef.current = null
pendingXmlRef.current = null
if (pendingXml) {
handleDisplayChart(
pendingXml,
false,
)
lastProcessedXmlRef.current.set(
toolCallId,
pendingXml,
)
}
},
STREAMING_DEBOUNCE_MS,
)
}
handleDisplayChart(xml)
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final output - process immediately (clear any pending debounce)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
pendingXmlRef.current = null
}
// Show toast only if final XML is malformed
handleDisplayChart(xml, true)
handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId)
}
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (
part.type === "tool-edit_diagram" &&
input?.operations
) {
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
if (
!editDiagramOriginalXmlRef.current.has(
toolCallId,
)
) {
if (!chartXML) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
return
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (!originalXml) return
// Skip if no change from last processed state
const lastCount = lastProcessedXmlRef.current.get(
toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
if (
state === "input-streaming" ||
state === "input-available"
) {
// Queue the operations for debounced processing
pendingEditRef.current = {
operations: completeOps,
toolCallId,
}
if (!editDebounceTimeoutRef.current) {
editDebounceTimeoutRef.current = setTimeout(
() => {
const pending =
pendingEditRef.current
editDebounceTimeoutRef.current =
null
pendingEditRef.current = null
if (pending) {
const origXml =
editDiagramOriginalXmlRef.current.get(
pending.toolCallId,
)
if (!origXml) return
try {
const {
result: editedXml,
} = applyDiagramOperations(
origXml,
pending.operations,
)
handleDisplayChart(
editedXml,
false,
)
lastProcessedXmlRef.current.set(
pending.toolCallId +
"-opCount",
String(
pending.operations
.length,
),
)
} catch (e) {
console.warn(
`[edit_diagram streaming] Operation failed:`,
e instanceof Error
? e.message
: e,
)
}
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final state - cleanup streaming refs (tool handler does final application)
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
}
}
}
})
}
})
// Cleanup: clear any pending debounce timeout on unmount
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
}
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
}
}, [messages, handleDisplayChart, chartXML])
}, [messages, handleDisplayChart])
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
@@ -662,23 +296,11 @@ export function ChatMessageDisplay({
Complete
</span>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</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>
)}
{input && Object.keys(input).length > 0 && (
<button
type="button"
@@ -699,9 +321,9 @@ export function ChatMessageDisplay({
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
@@ -711,23 +333,11 @@ export function ChatMessageDisplay({
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{output && state === "output-error" && (
<div className="px-4 py-3 border-t border-border/40 text-sm text-red-600">
{output}
</div>
)}
</div>
)
}
@@ -778,9 +388,7 @@ export function ChatMessageDisplay({
message.id,
)
setEditText(
getUserOriginalText(
message,
),
userMessageText,
)
}}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
@@ -821,52 +429,6 @@ export function ChatMessageDisplay({
</div>
)}
<div className="max-w-[85%] min-w-0">
{/* Reasoning blocks - displayed first for assistant messages */}
{message.role === "assistant" &&
message.parts?.map(
(part, partIndex) => {
if (part.type === "reasoning") {
const reasoningPart =
part as {
type: "reasoning"
text: string
}
const isLastPart =
partIndex ===
(message.parts
?.length ?? 0) -
1
const isLastMessage =
message.id ===
messages[
messages.length - 1
]?.id
const isStreamingReasoning =
status ===
"streaming" &&
isLastPart &&
isLastMessage
return (
<Reasoning
key={`${message.id}-reasoning-${partIndex}`}
className="w-full"
isStreaming={
isStreamingReasoning
}
>
<ReasoningTrigger />
<ReasoningContent>
{
reasoningPart.text
}
</ReasoningContent>
</Reasoning>
)
}
return null
},
)}
{/* Edit mode for user messages */}
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
@@ -1042,9 +604,7 @@ export function ChatMessageDisplay({
message.id,
)
setEditText(
getUserOriginalText(
message,
),
userMessageText,
)
}
}}
@@ -1064,9 +624,7 @@ export function ChatMessageDisplay({
message.id,
)
setEditText(
getUserOriginalText(
message,
),
userMessageText,
)
}
}}
@@ -1088,126 +646,26 @@ export function ChatMessageDisplay({
part.type ===
"text"
) {
const textContent =
(
part as {
text: string
}
)
.text
const sections =
splitTextIntoFileSections(
textContent,
)
return (
<div
key={`${message.id}-text-${group.startIndex}-${partIndex}`}
className="space-y-2"
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"
}`}
>
{sections.map(
(
section,
sectionIndex,
) => {
if (
section.type ===
"file"
) {
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
const isExpanded =
expandedPdfSections[
pdfKey
] ??
false
const charDisplay =
section.charCount &&
section.charCount >=
1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
<div
key={
pdfKey
}
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
>
<button
type="button"
onClick={(
e,
) => {
e.stopPropagation()
setExpandedPdfSections(
(
prev,
) => ({
...prev,
[pdfKey]:
!isExpanded,
}),
)
}}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{section.fileType ===
"pdf" ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
{
section.filename
}
</span>
<span className="text-[10px] text-muted-foreground">
(
{
charDisplay
}{" "}
chars)
</span>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
{
section.content
}
</pre>
</div>
)}
</div>
)
}
// Regular text section
return (
<div
key={`${message.id}-textsection-${partIndex}-${sectionIndex}`}
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>
{
section.content
}
</ReactMarkdown>
</div>
<ReactMarkdown>
{
(
part as {
text: string
}
)
},
)}
.text
}
</ReactMarkdown>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,15 @@
"use client"
import { FileCode, FileText, Loader2, X } from "lucide-react"
import { X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
function formatCharCount(count: number): string {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`
}
return String(count)
}
interface FilePreviewListProps {
files: File[]
onRemoveFile: (fileToRemove: File) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
}
export function FilePreviewList({
files,
onRemoveFile,
pdfData = new Map(),
}: FilePreviewListProps) {
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())
@@ -64,8 +48,6 @@ export function FilePreviewList({
imageUrlsRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
// Clear the ref so StrictMode remount creates fresh URLs
imageUrlsRef.current = new Map()
}
}, [])
@@ -86,19 +68,12 @@ export function FilePreviewList({
<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 pdfInfo = pdfData.get(file)
return (
<div key={file.name + index} className="relative group">
<div
className={`w-20 h-20 border rounded-md overflow-hidden bg-muted ${
file.type.startsWith("image/") && imageUrl
? "cursor-pointer"
: ""
}`}
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
onClick={() =>
file.type.startsWith("image/") &&
imageUrl &&
setSelectedImage(imageUrl)
imageUrl && setSelectedImage(imageUrl)
}
>
{file.type.startsWith("image/") && imageUrl ? (
@@ -108,35 +83,7 @@ export function FilePreviewList({
width={80}
height={80}
className="object-cover w-full h-full"
unoptimized
/>
) : isPdfFile(file) || isTextFile(file) ? (
<div className="flex flex-col items-center justify-center h-full p-1">
{pdfInfo?.isExtracting ? (
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
) : isPdfFile(file) ? (
<FileText className="h-6 w-6 text-red-500 mb-1" />
) : (
<FileCode className="h-6 w-6 text-blue-500 mb-1" />
)}
<span className="text-xs text-center truncate w-full px-1">
{file.name.length > 10
? `${file.name.slice(0, 7)}...`
: file.name}
</span>
{pdfInfo?.isExtracting ? (
<span className="text-[10px] text-muted-foreground">
Reading...
</span>
) : pdfInfo?.charCount ? (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
pdfInfo.charCount,
)}{" "}
chars
</span>
) : null}
</div>
) : (
<div className="flex items-center justify-center h-full text-xs text-center p-1">
{file.name}
@@ -177,7 +124,6 @@ export function FilePreviewList({
height={900}
className="object-contain max-w-full max-h-[90vh] w-auto h-auto"
onClick={(e) => e.stopPropagation()}
unoptimized
/>
</div>
</div>

View File

@@ -1,115 +0,0 @@
"use client"
import { Coffee, X } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { FaGithub } from "react-icons/fa"
interface QuotaLimitToastProps {
type?: "request" | "token"
used: number
limit: number
onDismiss: () => void
}
export function QuotaLimitToast({
type = "request",
used,
limit,
onDismiss,
}: QuotaLimitToastProps) {
const isTokenLimit = type === "token"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
onDismiss()
}
}
return (
<div
role="alert"
aria-live="polite"
tabIndex={0}
onKeyDown={handleKeyDown}
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
>
{/* Close button */}
<button
onClick={onDismiss}
className="absolute right-3 top-3 p-1.5 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2.5 mb-3 pr-6">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
<Coffee
className="w-4 h-4 text-accent-foreground"
strokeWidth={2}
/>
</div>
<h3 className="font-semibold text-foreground text-sm">
{isTokenLimit
? "Daily Token Limit Reached"
: "Daily Quota Reached"}
</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
{isTokenLimit
? `${formatNumber(used)}/${formatNumber(limit)} tokens`
: `${used}/${limit}`}
</span>
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>
Oops you've reached the daily{" "}
{isTokenLimit ? "token" : "API"} limit for this demo! As an
indie developer covering all the API costs myself, I have to
set these limits to keep things sustainable.{" "}
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-amber-600 font-medium hover:text-amber-700 hover:underline"
>
Learn more
</Link>
</p>
<p>
<strong>Tip:</strong> You can use your own API key (click
the Settings icon) or self-host the project to bypass these
limits.
</p>
<p>Your limit resets tomorrow. Thanks for understanding!</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
Self-host
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
Sponsor
</a>
</div>
</div>
)
}

View File

@@ -1,94 +1,37 @@
"use client"
import { Moon, Sun } from "lucide-react"
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"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
function getStoredAccessCodeRequired(): boolean | null {
if (typeof window === "undefined") return null
const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)
if (stored === null) return null
return stored === "true"
}
export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
drawioUi,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [provider, setProvider] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [apiKey, setApiKey] = useState("")
const [modelId, setModelId] = useState("")
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch("/api/config")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((data) => {
const required = data?.accessCodeRequired === true
localStorage.setItem(
STORAGE_ACCESS_CODE_REQUIRED_KEY,
String(required),
)
setAccessCodeRequired(required)
})
.catch(() => {
// Don't cache on error - allow retry on next mount
setAccessCodeRequired(false)
})
}, [])
useEffect(() => {
if (open) {
@@ -101,24 +44,16 @@ export function SettingsDialog({
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
// Load AI provider settings
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
setError("")
}
}, [open])
const handleSave = async () => {
if (!accessCodeRequired) return
setError("")
setIsVerifying(true)
try {
// Verify access code with server
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
@@ -130,10 +65,17 @@ export function SettingsDialog({
if (!data.valid) {
setError(data.message || "Invalid access code")
setIsVerifying(false)
return
}
// Save settings only if verification passes
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
closeProtection.toString(),
)
onCloseProtectionChange?.(closeProtection)
onOpenChange(false)
} catch {
setError("Failed to verify access code")
@@ -155,258 +97,31 @@ export function SettingsDialog({
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure your application settings.
Configure your access settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">Access Code</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : "Save"}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
Required to use this application.
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label>AI Provider Settings</Label>
<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">
Use your own API key to bypass usage limits. Your
key is stored locally in your browser and is never
stored on the server.
Required if the server has enabled access control.
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">Provider</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
const actualValue =
value === "default" ? "" : value
setProvider(actualValue)
localStorage.setItem(
STORAGE_AI_PROVIDER_KEY,
actualValue,
)
}}
>
<SelectTrigger id="ai-provider">
<SelectValue placeholder="Use Server Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Use Server Default
</SelectItem>
<SelectItem value="openai">
OpenAI
</SelectItem>
<SelectItem value="anthropic">
Anthropic
</SelectItem>
<SelectItem value="google">
Google
</SelectItem>
<SelectItem value="azure">
Azure OpenAI
</SelectItem>
<SelectItem value="openrouter">
OpenRouter
</SelectItem>
<SelectItem value="deepseek">
DeepSeek
</SelectItem>
<SelectItem value="siliconflow">
SiliconFlow
</SelectItem>
</SelectContent>
</Select>
</div>
{provider && provider !== "default" && (
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
Model ID
</Label>
<Input
id="ai-model"
value={modelId}
onChange={(e) => {
setModelId(e.target.value)
localStorage.setItem(
STORAGE_AI_MODEL_KEY,
e.target.value,
)
}}
placeholder={
provider === "openai"
? "e.g., gpt-4o"
: provider === "anthropic"
? "e.g., claude-sonnet-4-5"
: provider === "google"
? "e.g., gemini-2.0-flash-exp"
: provider ===
"deepseek"
? "e.g., deepseek-chat"
: "Model ID"
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
API Key
</Label>
<Input
id="ai-api-key"
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
localStorage.setItem(
STORAGE_AI_API_KEY_KEY,
e.target.value,
)
}}
placeholder="Your API key"
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Overrides{" "}
{provider === "openai"
? "OPENAI_API_KEY"
: provider === "anthropic"
? "ANTHROPIC_API_KEY"
: provider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: provider === "azure"
? "AZURE_API_KEY"
: provider ===
"openrouter"
? "OPENROUTER_API_KEY"
: provider ===
"deepseek"
? "DEEPSEEK_API_KEY"
: provider ===
"siliconflow"
? "SILICONFLOW_API_KEY"
: "server API key"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
Base URL (optional)
</Label>
<Input
id="ai-base-url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value)
localStorage.setItem(
STORAGE_AI_BASE_URL_KEY,
e.target.value,
)
}}
placeholder={
provider === "anthropic"
? "https://api.anthropic.com/v1"
: provider === "siliconflow"
? "https://api.siliconflow.com/v1"
: "Custom endpoint URL"
}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
localStorage.removeItem(
STORAGE_AI_PROVIDER_KEY,
)
localStorage.removeItem(
STORAGE_AI_BASE_URL_KEY,
)
localStorage.removeItem(
STORAGE_AI_API_KEY_KEY,
)
localStorage.removeItem(
STORAGE_AI_MODEL_KEY,
)
setProvider("")
setBaseUrl("")
setApiKey("")
setModelId("")
}}
>
Clear Settings
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">Theme</Label>
<p className="text-[0.8rem] text-muted-foreground">
Dark/Light mode for interface and DrawIO canvas.
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">DrawIO Style</Label>
<p className="text-[0.8rem] text-muted-foreground">
Canvas style:{" "}
{drawioUi === "min" ? "Minimal" : "Sketch"}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
Switch to{" "}
{drawioUi === "min" ? "Sketch" : "Minimal"}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
@@ -419,17 +134,21 @@ export function SettingsDialog({
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
onCheckedChange={setCloseProtection}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isVerifying}>
{isVerifying ? "Verifying..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)

View File

@@ -1,33 +0,0 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -3,9 +3,8 @@
import type React from "react"
import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
interface DiagramContextType {
chartXML: string
@@ -23,10 +22,8 @@ interface DiagramContextType {
format: ExportFormat,
sessionId?: string,
) => void
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -48,16 +45,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
console.log("[DiagramContext] DrawIO loaded, setting ready state")
setIsDrawioReady(true)
}
const resetDrawioReady = () => {
// console.log("[DiagramContext] Resetting DrawIO ready state")
hasCalledOnLoadRef.current = false
setIsDrawioReady(false)
}
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
@@ -83,62 +73,25 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Save current diagram to localStorage (used before theme/UI changes)
const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return
try {
const currentXml = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 2000),
),
])
// Only save if diagram has meaningful content (not empty template)
if (currentXml && currentXml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
}
} catch (error) {
console.error("Failed to save diagram to storage:", error)
}
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
): string | null => {
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) {
const validation = validateAndFixXml(chart)
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
validation.error,
)
return validation.error
}
// Use fixed XML if auto-fix was applied
if (validation.fixed) {
console.log(
"[loadDiagram] Auto-fixed XML issues:",
validation.fixes,
)
xmlToLoad = validation.fixed
const validationError = validateMxCellStructure(chart)
if (validationError) {
console.warn("[loadDiagram] Validation error:", validationError)
return validationError
}
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
setChartXML(xmlToLoad)
setChartXML(chart)
if (drawioRef.current) {
drawioRef.current.load({
xml: xmlToLoad,
xml: chart,
})
}
@@ -163,20 +116,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
if (expectHistoryExportRef.current) {
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: extractedXML,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE)
})
setDiagramHistory((prev) => [
...prev,
{
svg: data.data,
xml: extractedXML,
},
])
expectHistoryExportRef.current = false
}
@@ -224,9 +171,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = xmlContent
mimeType = "application/xml"
extension = ".drawio"
// Save to localStorage when user manually saves
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
@@ -305,10 +249,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport,
clearDiagram,
saveDiagramToFile,
saveDiagramToStorage,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
}}
>
{children}

View File

@@ -1,12 +0,0 @@
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
ports: ["3000:3000"]
env_file: .env
depends_on: [drawio]

View File

@@ -80,23 +80,13 @@ SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflo
```bash
AZURE_API_KEY=your_api_key
AZURE_RESOURCE_NAME=your-resource-name # Required: your Azure resource name
AI_MODEL=your-deployment-name
```
Or use a custom endpoint instead of resource name:
Optional custom endpoint:
```bash
AZURE_API_KEY=your_api_key
AZURE_BASE_URL=https://your-resource.openai.azure.com # Alternative to AZURE_RESOURCE_NAME
AI_MODEL=your-deployment-name
```
Optional reasoning configuration:
```bash
AZURE_REASONING_EFFORT=low # Optional: low, medium, high
AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
AZURE_BASE_URL=https://your-resource.openai.azure.com
```
### AWS Bedrock
@@ -108,7 +98,7 @@ AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
Note: On AWS (Amplify, Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
@@ -136,23 +126,6 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434
```
### Vercel AI Gateway
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
Model format uses `provider/model` syntax:
- `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
## 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`.
@@ -160,7 +133,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
```
## Model Capability Requirements

View File

@@ -1,39 +0,0 @@
# Offline Deployment
Deploy Next AI Draw.io offline by self-hosting draw.io to replace `embed.diagrams.net`.
**Note:** `NEXT_PUBLIC_DRAWIO_BASE_URL` is a **build-time** variable. Changing it requires rebuilding the Docker image.
## Docker Compose Setup
1. Clone the repository and define API keys in `.env`.
2. Create `docker-compose.yml`:
```yaml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
ports: ["3000:3000"]
env_file: .env
depends_on: [drawio]
```
3. Run `docker compose up -d` and open `http://localhost:3000`.
## Configuration & Critical Warning
**The `NEXT_PUBLIC_DRAWIO_BASE_URL` must be accessible from the user's browser.**
| Scenario | URL Value |
|----------|-----------|
| Localhost | `http://localhost:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
# Default: bedrock
AI_PROVIDER=bedrock
@@ -11,49 +11,28 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id
# AWS_SECRET_ACCESS_KEY=your-secret-access-key
# Note: Claude and Nova models support reasoning/extended thinking
# BEDROCK_REASONING_BUDGET_TOKENS=12000 # Optional: Claude reasoning budget in tokens (1024-64000)
# BEDROCK_REASONING_EFFORT=medium # Optional: Nova reasoning effort (low/medium/high)
# OpenAI Configuration
# OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
# OPENAI_ORGANIZATION=org-... # Optional
# OPENAI_PROJECT=proj_... # Optional
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
# Anthropic (Direct) Configuration
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
# Google Generative AI Configuration
# GOOGLE_GENERATIVE_AI_API_KEY=...
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
# Azure OpenAI Configuration
# Configure endpoint using ONE of these methods:
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
# 2. AZURE_BASE_URL - SDK appends /v1{path} to your URL
# If both are set, AZURE_BASE_URL takes precedence.
# AZURE_RESOURCE_NAME=your-resource-name
# AZURE_API_KEY=...
# AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # Alternative: Custom endpoint
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
# AZURE_REASONING_SUMMARY=detailed
# 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
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
# OpenRouter Configuration
# OPENROUTER_API_KEY=sk-or-v1-...
@@ -68,11 +47,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
# AI_GATEWAY_API_KEY=...
# Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-...
@@ -86,13 +60,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code
# Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
# PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)

View File

@@ -1,26 +0,0 @@
import { STORAGE_KEYS } from "./storage"
/**
* Get AI configuration from localStorage.
* Returns API keys and settings for custom AI providers.
* Used to override server defaults when user provides their own API key.
*/
export function getAIConfig() {
if (typeof window === "undefined") {
return {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
}
}
return {
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
}
}

View File

@@ -2,7 +2,6 @@ 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 { gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -19,7 +18,6 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "gateway"
interface ModelConfig {
model: any
@@ -28,25 +26,6 @@ interface ModelConfig {
modelId: string
}
export interface ClientOverrides {
provider?: string | null
baseUrl?: string | null
apiKey?: string | null
modelId?: string | null
}
// Providers that can be used with client-provided API keys
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openai",
"anthropic",
"google",
"azure",
"openrouter",
"deepseek",
"siliconflow",
"gateway",
]
// Bedrock provider options for Anthropic beta features
const BEDROCK_ANTHROPIC_BETA = {
bedrock: {
@@ -59,297 +38,6 @@ const ANTHROPIC_BETA_HEADERS = {
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
}
/**
* Safely parse integer from environment variable with validation
*/
function parseIntSafe(
value: string | undefined,
varName: string,
min?: number,
max?: number,
): number | undefined {
if (!value) return undefined
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
throw new Error(`${varName} must be a valid integer, got: ${value}`)
}
if (min !== undefined && parsed < min) {
throw new Error(`${varName} must be >= ${min}, got: ${parsed}`)
}
if (max !== undefined && parsed > max) {
throw new Error(`${varName} must be <= ${max}, got: ${parsed}`)
}
return parsed
}
/**
* Build provider-specific options from environment variables
* Supports various AI SDK providers with their unique configuration options
*
* Environment variables:
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
* - BEDROCK_REASONING_EFFORT: Bedrock Nova reasoning effort (low/medium/high)
* - OLLAMA_ENABLE_THINKING: Enable Ollama thinking mode (set to "true")
*/
function buildProviderOptions(
provider: ProviderName,
modelId?: string,
): Record<string, any> | undefined {
const options: Record<string, any> = {}
switch (provider) {
case "openai": {
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
if (
modelId &&
(modelId.includes("o1") ||
modelId.includes("o3") ||
modelId.includes("gpt-5"))
) {
options.openai = {
// Auto-enable reasoning summary for reasoning models (default: detailed)
reasoningSummary:
(reasoningSummary as "none" | "brief" | "detailed") ||
"detailed",
}
// Optionally configure reasoning effort
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
} else if (reasoningEffort || reasoningSummary) {
// Non-reasoning models: only apply if explicitly configured
options.openai = {}
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "none"
| "brief"
| "detailed"
}
}
break
}
case "anthropic": {
const thinkingBudget = parseIntSafe(
process.env.ANTHROPIC_THINKING_BUDGET_TOKENS,
"ANTHROPIC_THINKING_BUDGET_TOKENS",
1024,
64000,
)
const thinkingType =
process.env.ANTHROPIC_THINKING_TYPE || "enabled"
if (thinkingBudget) {
options.anthropic = {
thinking: {
type: thinkingType,
budgetTokens: thinkingBudget,
},
}
}
break
}
case "google": {
const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT
const thinkingBudgetVal = parseIntSafe(
process.env.GOOGLE_THINKING_BUDGET,
"GOOGLE_THINKING_BUDGET",
1024,
100000,
)
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
// to return the reasoning in the response
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
// Optionally configure thinking budget or level
if (
thinkingBudgetVal &&
(modelId.includes("2.5") || modelId.includes("2-5"))
) {
thinkingConfig.thinkingBudget = thinkingBudgetVal
} else if (
thinkingLevel &&
(modelId.includes("gemini-3") ||
modelId.includes("gemini3"))
) {
thinkingConfig.thinkingLevel = thinkingLevel as
| "low"
| "high"
}
options.google = { thinkingConfig }
} else if (reasoningEffort) {
options.google = {
reasoningEffort: reasoningEffort as
| "low"
| "medium"
| "high",
}
}
// Keep existing Google options
const options_obj: Record<string, any> = {}
const candidateCount = parseIntSafe(
process.env.GOOGLE_CANDIDATE_COUNT,
"GOOGLE_CANDIDATE_COUNT",
1,
8,
)
if (candidateCount) {
options_obj.candidateCount = candidateCount
}
const topK = parseIntSafe(
process.env.GOOGLE_TOP_K,
"GOOGLE_TOP_K",
1,
100,
)
if (topK) {
options_obj.topK = topK
}
if (process.env.GOOGLE_TOP_P) {
const topP = Number.parseFloat(process.env.GOOGLE_TOP_P)
if (Number.isNaN(topP) || topP < 0 || topP > 1) {
throw new Error(
`GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`,
)
}
options_obj.topP = topP
}
if (Object.keys(options_obj).length > 0) {
options.google = { ...options.google, ...options_obj }
}
break
}
case "azure": {
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
if (reasoningEffort || reasoningSummary) {
options.azure = {}
if (reasoningEffort) {
options.azure.reasoningEffort = reasoningEffort as
| "low"
| "medium"
| "high"
}
if (reasoningSummary) {
options.azure.reasoningSummary = reasoningSummary as
| "none"
| "brief"
| "detailed"
}
}
break
}
case "bedrock": {
const budgetTokens = parseIntSafe(
process.env.BEDROCK_REASONING_BUDGET_TOKENS,
"BEDROCK_REASONING_BUDGET_TOKENS",
1024,
64000,
)
const reasoningEffort = process.env.BEDROCK_REASONING_EFFORT
// Bedrock reasoning ONLY for Claude and Nova models
// Other models (MiniMax, etc.) don't support reasoningConfig
if (
modelId &&
(budgetTokens || reasoningEffort) &&
(modelId.includes("claude") ||
modelId.includes("anthropic") ||
modelId.includes("nova") ||
modelId.includes("amazon"))
) {
const reasoningConfig: Record<string, any> = { type: "enabled" }
// Claude models: use budgetTokens (1024-64000)
if (
budgetTokens &&
(modelId.includes("claude") ||
modelId.includes("anthropic"))
) {
reasoningConfig.budgetTokens = budgetTokens
}
// Nova models: use maxReasoningEffort (low/medium/high)
else if (
reasoningEffort &&
(modelId.includes("nova") || modelId.includes("amazon"))
) {
reasoningConfig.maxReasoningEffort = reasoningEffort as
| "low"
| "medium"
| "high"
}
options.bedrock = { reasoningConfig }
}
break
}
case "ollama": {
const enableThinking = process.env.OLLAMA_ENABLE_THINKING
// Ollama supports reasoning with think: true for models like qwen3
if (enableThinking === "true") {
options.ollama = { think: true }
}
break
}
case "deepseek":
case "openrouter":
case "siliconflow":
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
}
default:
break
}
return Object.keys(options).length > 0 ? options : undefined
}
// 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
@@ -361,7 +49,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
/**
@@ -377,16 +64,7 @@ function detectProvider(): ProviderName | null {
continue
}
if (process.env[envVar]) {
// Azure requires additional config (baseURL or resourceName)
if (provider === "azure") {
const hasBaseUrl = !!process.env.AZURE_BASE_URL
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
if (hasBaseUrl || hasResourceName) {
configuredProviders.push(provider as ProviderName)
}
} else {
configuredProviders.push(provider as ProviderName)
}
configuredProviders.push(provider as ProviderName)
}
}
@@ -408,18 +86,6 @@ function validateProviderCredentials(provider: ProviderName): void {
`Please set it in your .env.local file.`,
)
}
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
if (provider === "azure") {
const hasBaseUrl = !!process.env.AZURE_BASE_URL
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
if (!hasBaseUrl && !hasResourceName) {
throw new Error(
`Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` +
`Please set one in your .env.local file.`,
)
}
}
}
/**
@@ -443,49 +109,18 @@ function validateProviderCredentials(provider: ProviderName): void {
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
*/
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints.
if (overrides?.baseUrl && !overrides?.apiKey) {
throw new Error(
`API key is required when using a custom base URL. ` +
`Please provide your own API key in Settings.`,
)
}
// Check if client is providing their own provider override
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
// Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL
export function getAIModel(): ModelConfig {
const modelId = process.env.AI_MODEL
if (!modelId) {
if (isClientOverride) {
throw new Error(
`Model ID is required when using custom AI provider. Please specify a model in Settings.`,
)
}
throw new Error(
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
)
}
// Determine provider: client override > explicit config > auto-detect > error
// Determine provider: explicit config > auto-detect > error
let provider: ProviderName
if (overrides?.provider) {
// Validate client-provided provider
if (
!ALLOWED_CLIENT_PROVIDERS.includes(
overrides.provider as ProviderName,
)
) {
throw new Error(
`Invalid provider: ${overrides.provider}. Allowed providers: ${ALLOWED_CLIENT_PROVIDERS.join(", ")}`,
)
}
provider = overrides.provider as ProviderName
} else if (process.env.AI_PROVIDER) {
if (process.env.AI_PROVIDER) {
provider = process.env.AI_PROVIDER as ProviderName
} else {
const detected = detectProvider()
@@ -501,7 +136,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
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` +
`- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` +
`- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` +
@@ -521,10 +155,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
}
// Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) {
validateProviderCredentials(provider)
}
// Validate provider credentials
validateProviderCredentials(provider)
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
@@ -532,12 +164,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
let providerOptions: any
let headers: Record<string, string> | undefined
// Build provider-specific options from environment variables
const customProviderOptions = buildProviderOptions(provider, modelId)
switch (provider) {
case "bedrock": {
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || "us-west-2",
@@ -546,43 +175,29 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) {
// Deep merge to preserve both anthropicBeta and reasoningConfig
providerOptions = {
bedrock: {
...BEDROCK_ANTHROPIC_BETA.bedrock,
...(customProviderOptions?.bedrock || {}),
},
}
} else if (customProviderOptions) {
providerOptions = customProviderOptions
providerOptions = BEDROCK_ANTHROPIC_BETA
}
break
}
case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
if (baseURL || overrides?.apiKey) {
case "openai":
if (process.env.OPENAI_BASE_URL) {
const customOpenAI = createOpenAI({
apiKey,
...(baseURL && { baseURL }),
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
})
model = customOpenAI.chat(modelId)
} else {
model = openai(modelId)
}
break
}
case "anthropic": {
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.ANTHROPIC_BASE_URL ||
"https://api.anthropic.com/v1"
const customProvider = createAnthropic({
apiKey,
baseURL,
apiKey: process.env.ANTHROPIC_API_KEY,
baseURL:
process.env.ANTHROPIC_BASE_URL ||
"https://api.anthropic.com/v1",
headers: ANTHROPIC_BETA_HEADERS,
})
model = customProvider(modelId)
@@ -591,41 +206,29 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "google": {
const apiKey =
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
if (baseURL || overrides?.apiKey) {
case "google":
if (process.env.GOOGLE_BASE_URL) {
const customGoogle = createGoogleGenerativeAI({
apiKey,
...(baseURL && { baseURL }),
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL,
})
model = customGoogle(modelId)
} else {
model = google(modelId)
}
break
}
case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
const resourceName = process.env.AZURE_RESOURCE_NAME
// Azure requires either baseURL or resourceName to construct the endpoint
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
if (baseURL || resourceName || overrides?.apiKey) {
case "azure":
if (process.env.AZURE_BASE_URL) {
const customAzure = createAzure({
apiKey,
// baseURL takes precedence over resourceName per SDK behavior
...(baseURL && { baseURL }),
...(!baseURL && resourceName && { resourceName }),
apiKey: process.env.AZURE_API_KEY,
baseURL: process.env.AZURE_BASE_URL,
})
model = customAzure(modelId)
} else {
model = azure(modelId)
}
break
}
case "ollama":
if (process.env.OLLAMA_BASE_URL) {
@@ -639,78 +242,44 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
case "openrouter": {
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const baseURL =
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
const openrouter = createOpenRouter({
apiKey,
...(baseURL && { baseURL }),
apiKey: process.env.OPENROUTER_API_KEY,
...(process.env.OPENROUTER_BASE_URL && {
baseURL: process.env.OPENROUTER_BASE_URL,
}),
})
model = openrouter(modelId)
break
}
case "deepseek": {
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
if (baseURL || overrides?.apiKey) {
case "deepseek":
if (process.env.DEEPSEEK_BASE_URL) {
const customDeepSeek = createDeepSeek({
apiKey,
...(baseURL && { baseURL }),
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL,
})
model = customDeepSeek(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.SILICONFLOW_BASE_URL ||
"https://api.siliconflow.com/v1"
const siliconflowProvider = createOpenAI({
apiKey,
baseURL,
apiKey: process.env.SILICONFLOW_API_KEY,
baseURL:
process.env.SILICONFLOW_BASE_URL ||
"https://api.siliconflow.com/v1",
})
model = siliconflowProvider.chat(modelId)
break
}
case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway
model = gateway(modelId)
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
)
}
// Apply provider-specific options for all providers except bedrock (which has special handling)
if (customProviderOptions && provider !== "bedrock" && !providerOptions) {
providerOptions = customProviderOptions
}
return { model, providerOptions, headers, modelId }
}
/**
* Check if a model supports prompt caching.
* Currently only Claude models on Bedrock support prompt caching.
*/
export function supportsPromptCaching(modelId: string): boolean {
// Bedrock prompt caching is supported for Claude models
return (
modelId.includes("claude") ||
modelId.includes("anthropic") ||
modelId.startsWith("us.anthropic") ||
modelId.startsWith("eu.anthropic")
)
}

View File

@@ -9,7 +9,12 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
promptText:
"Give me a **animated connector** diagram of transformer's architecture",
hasImage: false,
xml: `<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">
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>
@@ -249,12 +254,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<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>`,
</mxCell>
</root>`,
},
{
promptText: "Replicate this in aws style",
hasImage: true,
xml: `<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">
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>
@@ -313,12 +324,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Replicate this flowchart.",
hasImage: true,
xml: `<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">
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>
@@ -374,368 +391,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<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>`,
},
{
promptText: "Summarize this paper as a diagram",
hasImage: true,
xml: `<mxCell id="title_bg" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
value="" vertex="1">
<mxGeometry height="80" width="720" x="40" y="20" as="geometry" />
</mxCell>
<mxCell id="title" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=22;fontStyle=1;fontColor=#FFFFFF;"
value="Chain-of-Thought Prompting&lt;br&gt;&lt;font style=&quot;font-size: 14px;&quot;&gt;Elicits Reasoning in Large Language Models&lt;/font&gt;"
vertex="1">
<mxGeometry height="70" width="720" x="40" y="25" as="geometry" />
</mxCell>
<mxCell id="authors" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontColor=#666666;"
value="Wei et al. (Google Research, Brain Team) | NeurIPS 2022" vertex="1">
<mxGeometry height="20" width="720" x="40" y="100" as="geometry" />
</mxCell>
<mxCell id="core_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="💡 Core Idea" vertex="1">
<mxGeometry height="30" width="150" x="40" y="125" as="geometry" />
</mxCell>
<mxCell id="core_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;align=left;spacingLeft=10;spacingRight=10;fontSize=11;"
value="&lt;b&gt;Chain of Thought&lt;/b&gt; = A series of intermediate reasoning steps that lead to the final answer&lt;br&gt;&lt;br&gt;Simply provide a few CoT demonstrations as exemplars in few-shot prompting"
vertex="1">
<mxGeometry height="75" width="340" x="40" y="155" as="geometry" />
</mxCell>
<mxCell id="compare_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="⚖️ Standard vs Chain-of-Thought Prompting" vertex="1">
<mxGeometry height="30" width="350" x="40" y="240" as="geometry" />
</mxCell>
<mxCell id="std_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;arcSize=8;"
value="" vertex="1">
<mxGeometry height="160" width="170" x="40" y="275" as="geometry" />
</mxCell>
<mxCell id="std_title" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#C62828;"
value="Standard Prompting" vertex="1">
<mxGeometry height="25" width="170" x="40" y="280" as="geometry" />
</mxCell>
<mxCell id="std_q" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;spacingLeft=5;spacingRight=5;"
value="Q: Roger has 5 tennis balls. He buys 2 more cans. Each can has 3 balls. How many now?"
vertex="1">
<mxGeometry height="55" width="160" x="45" y="305" as="geometry" />
</mxCell>
<mxCell id="std_a" parent="1"
style="text;html=1;strokeColor=none;fillColor=#FFCDD2;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=10;fontStyle=1;spacingLeft=5;"
value="A: The answer is 11." vertex="1">
<mxGeometry height="25" width="150" x="50" y="365" as="geometry" />
</mxCell>
<mxCell id="std_result" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=1;fontColor=#C62828;"
value="❌ Often Wrong" vertex="1">
<mxGeometry height="30" width="170" x="40" y="400" as="geometry" />
</mxCell>
<mxCell id="cot_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;arcSize=8;"
value="" vertex="1">
<mxGeometry height="160" width="170" x="220" y="275" as="geometry" />
</mxCell>
<mxCell id="cot_title" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#2E7D32;"
value="Chain-of-Thought" vertex="1">
<mxGeometry height="25" width="170" x="220" y="280" as="geometry" />
</mxCell>
<mxCell id="cot_q" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;spacingLeft=5;spacingRight=5;"
value="Q: Roger has 5 tennis balls. He buys 2 more cans. Each can has 3 balls. How many now?"
vertex="1">
<mxGeometry height="55" width="160" x="225" y="305" as="geometry" />
</mxCell>
<mxCell id="cot_a" parent="1"
style="text;html=1;strokeColor=none;fillColor=#C8E6C9;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=9;fontStyle=1;spacingLeft=5;"
value="A: 2 cans × 3 = 6 balls.&lt;br&gt;5 + 6 = 11. Answer: 11" vertex="1">
<mxGeometry height="35" width="150" x="230" y="360" as="geometry" />
</mxCell>
<mxCell id="cot_result" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;fontStyle=1;fontColor=#2E7D32;"
value="✓ Correct!" vertex="1">
<mxGeometry height="30" width="170" x="220" y="400" as="geometry" />
</mxCell>
<mxCell id="vs_arrow" edge="1" parent="1"
style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;fillColor=#FFC107;strokeColor=none;width=8;endSize=4;startSize=4;"
value="">
<mxGeometry relative="1" width="100" as="geometry">
<mxPoint x="195" y="355" as="sourcePoint" />
<mxPoint x="235" y="355" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="props_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="🔑 Key Properties" vertex="1">
<mxGeometry height="30" width="150" x="400" y="125" as="geometry" />
</mxCell>
<mxCell id="prop1" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;"
value="1⃣ Decomposes multi-step problems" vertex="1">
<mxGeometry height="32" width="180" x="400" y="155" as="geometry" />
</mxCell>
<mxCell id="prop2" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;"
value="2⃣ Interpretable reasoning window" vertex="1">
<mxGeometry height="32" width="180" x="400" y="192" as="geometry" />
</mxCell>
<mxCell id="prop3" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;"
value="3⃣ Applicable to any language task" vertex="1">
<mxGeometry height="32" width="180" x="400" y="229" as="geometry" />
</mxCell>
<mxCell id="prop4" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#EF6C00;fontSize=10;align=left;spacingLeft=8;"
value="4⃣ No finetuning required" vertex="1">
<mxGeometry height="32" width="180" x="400" y="266" as="geometry" />
</mxCell>
<mxCell id="emergent_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="📈 Emergent Ability" vertex="1">
<mxGeometry height="30" width="180" x="400" y="310" as="geometry" />
</mxCell>
<mxCell id="emergent_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#F3E5F5;strokeColor=#7B1FA2;arcSize=8;"
value="" vertex="1">
<mxGeometry height="95" width="180" x="400" y="340" as="geometry" />
</mxCell>
<mxCell id="emergent_text" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;"
value="CoT only works with&lt;br&gt;&lt;b&gt;~100B+ parameters&lt;/b&gt;&lt;br&gt;&lt;br&gt;Small models produce&lt;br&gt;fluent but illogical chains"
vertex="1">
<mxGeometry height="85" width="180" x="400" y="345" as="geometry" />
</mxCell>
<mxCell id="results_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="📊 Key Results" vertex="1">
<mxGeometry height="30" width="150" x="600" y="125" as="geometry" />
</mxCell>
<mxCell id="gsm_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;arcSize=8;"
value="" vertex="1">
<mxGeometry height="100" width="160" x="600" y="155" as="geometry" />
</mxCell>
<mxCell id="gsm_title" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;fontColor=#2E7D32;"
value="GSM8K (Math)" vertex="1">
<mxGeometry height="20" width="160" x="600" y="160" as="geometry" />
</mxCell>
<mxCell id="gsm_bar1" parent="1"
style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFCDD2;strokeColor=none;"
value="" vertex="1">
<mxGeometry height="30" width="40" x="615" y="185" as="geometry" />
</mxCell>
<mxCell id="gsm_bar2" parent="1"
style="rounded=0;whiteSpace=wrap;html=1;fillColor=#4CAF50;strokeColor=none;"
value="" vertex="1">
<mxGeometry height="30" width="80" x="665" y="185" as="geometry" />
</mxCell>
<mxCell id="gsm_label1" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=1;"
value="18%" vertex="1">
<mxGeometry height="15" width="40" x="615" y="215" as="geometry" />
</mxCell>
<mxCell id="gsm_label2" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=1;fontColor=#2E7D32;"
value="57%" vertex="1">
<mxGeometry height="15" width="80" x="665" y="215" as="geometry" />
</mxCell>
<mxCell id="gsm_legend" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#666666;"
value="Standard → CoT (PaLM 540B)" vertex="1">
<mxGeometry height="20" width="160" x="600" y="232" as="geometry" />
</mxCell>
<mxCell id="bench_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="🧪 Benchmarks Tested" vertex="1">
<mxGeometry height="30" width="180" x="600" y="265" as="geometry" />
</mxCell>
<mxCell id="bench_arith" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;"
value="🔢 Arithmetic&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;GSM8K, SVAMP, ASDiv, AQuA, MAWPS&lt;/font&gt;"
vertex="1">
<mxGeometry height="45" width="160" x="600" y="295" as="geometry" />
</mxCell>
<mxCell id="bench_common" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;"
value="🧠 Commonsense&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;CSQA, StrategyQA, Date, Sports, SayCan&lt;/font&gt;"
vertex="1">
<mxGeometry height="45" width="160" x="600" y="345" as="geometry" />
</mxCell>
<mxCell id="bench_symbol" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;fontSize=10;align=center;"
value="🔣 Symbolic&lt;br&gt;&lt;font style=&quot;font-size: 9px;&quot;&gt;Last Letter Concat, Coin Flip&lt;/font&gt;"
vertex="1">
<mxGeometry height="40" width="160" x="600" y="395" as="geometry" />
</mxCell>
<mxCell id="task_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="🎯 Task Types &amp; Results" vertex="1">
<mxGeometry height="30" width="200" x="40" y="445" as="geometry" />
</mxCell>
<mxCell id="task_arith" parent="1"
style="ellipse;whiteSpace=wrap;html=1;fillColor=#BBDEFB;strokeColor=#1565C0;fontSize=11;fontStyle=1;"
value="Arithmetic&lt;br&gt;Reasoning" vertex="1">
<mxGeometry height="60" width="90" x="40" y="480" as="geometry" />
</mxCell>
<mxCell id="task_arith_res" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#1565C0;"
value="SOTA on GSM8K&lt;br&gt;(57% vs 55% prior)" vertex="1">
<mxGeometry height="30" width="110" x="30" y="540" as="geometry" />
</mxCell>
<mxCell id="task_common" parent="1"
style="ellipse;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;fontSize=11;fontStyle=1;"
value="Commonsense&lt;br&gt;Reasoning" vertex="1">
<mxGeometry height="60" width="90" x="160" y="480" as="geometry" />
</mxCell>
<mxCell id="task_common_res" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#2E7D32;"
value="SOTA StrategyQA&lt;br&gt;(75.6% vs 69.4%)" vertex="1">
<mxGeometry height="30" width="110" x="150" y="540" as="geometry" />
</mxCell>
<mxCell id="task_symbol" parent="1"
style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#EF6C00;fontSize=11;fontStyle=1;"
value="Symbolic&lt;br&gt;Reasoning" vertex="1">
<mxGeometry height="60" width="90" x="280" y="480" as="geometry" />
</mxCell>
<mxCell id="task_symbol_res" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fontSize=9;fontColor=#EF6C00;"
value="OOD Generalization&lt;br&gt;to longer sequences" vertex="1">
<mxGeometry height="30" width="110" x="270" y="540" as="geometry" />
</mxCell>
<mxCell id="task_arrow1" edge="1" parent="1"
style="endArrow=classic;html=1;strokeColor=#9E9E9E;strokeWidth=2;" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="130" y="510" as="sourcePoint" />
<mxPoint x="160" y="510" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="task_arrow2" edge="1" parent="1"
style="endArrow=classic;html=1;strokeColor=#9E9E9E;strokeWidth=2;" value="">
<mxGeometry height="50" relative="1" width="50" as="geometry">
<mxPoint x="250" y="510" as="sourcePoint" />
<mxPoint x="280" y="510" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="models_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="🤖 Models Tested" vertex="1">
<mxGeometry height="30" width="150" x="400" y="445" as="geometry" />
</mxCell>
<mxCell id="models_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ECEFF1;strokeColor=#607D8B;arcSize=8;"
value="" vertex="1">
<mxGeometry height="95" width="180" x="400" y="475" as="geometry" />
</mxCell>
<mxCell id="model1" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;"
value="• GPT-3 (175B)" vertex="1">
<mxGeometry height="20" width="90" x="400" y="480" as="geometry" />
</mxCell>
<mxCell id="model2" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;"
value="• LaMDA (137B)" vertex="1">
<mxGeometry height="20" width="90" x="400" y="500" as="geometry" />
</mxCell>
<mxCell id="model3" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;"
value="• PaLM (540B)" vertex="1">
<mxGeometry height="20" width="90" x="400" y="520" as="geometry" />
</mxCell>
<mxCell id="model4" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;"
value="• Codex" vertex="1">
<mxGeometry height="20" width="80" x="490" y="480" as="geometry" />
</mxCell>
<mxCell id="model5" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;spacingLeft=10;"
value="• UL2 (20B)" vertex="1">
<mxGeometry height="20" width="80" x="490" y="500" as="geometry" />
</mxCell>
<mxCell id="model_note" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontStyle=2;fontColor=#607D8B;"
value="No finetuning - prompting only!" vertex="1">
<mxGeometry height="20" width="180" x="400" y="545" as="geometry" />
</mxCell>
<mxCell id="takeaway_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;fontColor=#1a237e;"
value="✨ Key Takeaways" vertex="1">
<mxGeometry height="30" width="160" x="600" y="445" as="geometry" />
</mxCell>
<mxCell id="takeaway_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#FFA000;arcSize=8;"
value="" vertex="1">
<mxGeometry height="95" width="160" x="600" y="475" as="geometry" />
</mxCell>
<mxCell id="take1" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;"
value="✓ Simple yet powerful" vertex="1">
<mxGeometry height="18" width="150" x="605" y="480" as="geometry" />
</mxCell>
<mxCell id="take2" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;"
value="✓ Emergent at scale" vertex="1">
<mxGeometry height="18" width="150" x="605" y="498" as="geometry" />
</mxCell>
<mxCell id="take3" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;"
value="✓ Broadly applicable" vertex="1">
<mxGeometry height="18" width="150" x="605" y="516" as="geometry" />
</mxCell>
<mxCell id="take4" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;"
value="✓ No training needed" vertex="1">
<mxGeometry height="18" width="150" x="605" y="534" as="geometry" />
</mxCell>
<mxCell id="take5" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;spacingLeft=5;"
value="✓ State-of-the-art results" vertex="1">
<mxGeometry height="18" width="150" x="605" y="552" as="geometry" />
</mxCell>
<mxCell id="format_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;"
value="📝 Prompt Format" vertex="1">
<mxGeometry height="25" width="150" x="40" y="575" as="geometry" />
</mxCell>
<mxCell id="format_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E1BEE7;strokeColor=#7B1FA2;fontSize=12;fontStyle=1;"
value="〈 Input, Chain of Thought, Output 〉" vertex="1">
<mxGeometry height="35" width="250" x="40" y="600" as="geometry" />
</mxCell>
<mxCell id="limit_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;"
value="⚠️ Limitations" vertex="1">
<mxGeometry height="25" width="120" x="310" y="575" as="geometry" />
</mxCell>
<mxCell id="limit_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;fontSize=10;align=left;spacingLeft=8;"
value="• Requires large models (~100B+)&lt;br&gt;• No guarantee of correct reasoning&lt;br&gt;• Costly to serve in production"
vertex="1">
<mxGeometry height="55" width="200" x="310" y="600" as="geometry" />
</mxCell>
<mxCell id="impact_header" parent="1"
style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1;fontColor=#1a237e;"
value="🚀 Impact" vertex="1">
<mxGeometry height="25" width="100" x="530" y="575" as="geometry" />
</mxCell>
<mxCell id="impact_box" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#2E7D32;fontSize=10;align=left;spacingLeft=8;spacingRight=8;"
value="Foundational technique for modern LLM reasoning - inspired many follow-up works including Self-Consistency, Tree-of-Thought, etc."
vertex="1">
<mxGeometry height="55" width="230" x="530" y="600" as="geometry" />
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Draw a cat for me",
hasImage: false,
xml: `<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
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>
@@ -875,7 +542,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="235" y="290"/>
</Array>
</mxGeometry>
</mxCell>`,
</mxCell>
</root>`,
},
]

View File

@@ -84,7 +84,9 @@ export function getTelemetryConfig(params: {
return {
isEnabled: true,
recordInputs: 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,

View File

@@ -1,75 +0,0 @@
import { extractText, getDocumentProxy } from "unpdf"
// Maximum characters allowed for extracted text (configurable via env)
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
export const MAX_EXTRACTED_CHARS =
Number(process.env.NEXT_PUBLIC_MAX_EXTRACTED_CHARS) ||
DEFAULT_MAX_EXTRACTED_CHARS
// Text file extensions we support
const TEXT_EXTENSIONS = [
".txt",
".md",
".markdown",
".json",
".csv",
".xml",
".html",
".css",
".js",
".ts",
".jsx",
".tsx",
".py",
".java",
".c",
".cpp",
".h",
".go",
".rs",
".yaml",
".yml",
".toml",
".ini",
".log",
".sh",
".bash",
".zsh",
]
/**
* Extract text content from a PDF file
* Uses unpdf library for client-side extraction
*/
export async function extractPdfText(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { text } = await extractText(pdf, { mergePages: true })
return text as string
}
/**
* Check if a file is a PDF
*/
export function isPdfFile(file: File): boolean {
return file.type === "application/pdf" || file.name.endsWith(".pdf")
}
/**
* Check if a file is a text file
*/
export function isTextFile(file: File): boolean {
const name = file.name.toLowerCase()
return (
file.type.startsWith("text/") ||
file.type === "application/json" ||
TEXT_EXTENSIONS.some((ext) => name.endsWith(ext))
)
}
/**
* Extract text content from a text file
*/
export async function extractTextFileContent(file: File): Promise<string> {
return await file.text()
}

View File

@@ -1,27 +0,0 @@
// Centralized localStorage keys
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
export const STORAGE_KEYS = {
// Chat data
messages: "next-ai-draw-io-messages",
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
diagramXml: "next-ai-draw-io-diagram-xml",
sessionId: "next-ai-draw-io-session-id",
// Quota tracking
requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date",
tokenCount: "next-ai-draw-io-token-count",
tokenDate: "next-ai-draw-io-token-date",
tpmCount: "next-ai-draw-io-tpm-count",
tpmMinute: "next-ai-draw-io-tpm-minute",
// Settings
accessCode: "next-ai-draw-io-access-code",
closeProtection: "next-ai-draw-io-close-protection",
accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
} as const

View File

@@ -1,19 +1,23 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*
* Token counting utilities are in a separate file (token-counter.ts) to avoid
* WebAssembly issues with Next.js server-side rendering.
*/
// Default system prompt (~1900 tokens) - works with all models
// Default system prompt (~2700 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
You can see the image that user uploaded.
When you are asked to create a diagram, you must first tell user you plan in text first. Plan the layout and structure that can avoid object overlapping or edge cross the objects.
Then use display_diagram tool to generate the full draw.io XML for the entire diagram.
## Tone and style
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Be concise and to the point. Only use bullet points when needed for clarity.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting,.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools as means to communicate with the user during the session.
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
@@ -25,7 +29,7 @@ You can read and modify diagrams by generating draw.io XML code through tool cal
## 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/PDF Upload** (paperclip icon, bottom-left of chat input): Users can upload images or PDF documents for you to analyze and generate diagrams from.
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.
@@ -42,18 +46,11 @@ description: Edit specific parts of the EXISTING diagram. Use this when making s
parameters: {
edits: Array<{search: string, replace: string}>
}
---Tool3---
tool name: append_diagram
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
parameters: {
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
}
---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
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
@@ -88,33 +85,38 @@ Note that:
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
- For delete: only cell_id is needed
- Find the cell_id from "Current diagram XML" in system context
- Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]}
- Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
- 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.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## Draw.io XML Structure Reference
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
Example - generate ONLY this:
Basic structure:
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<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. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
4. Use unique sequential IDs starting from "2"
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
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
@@ -128,6 +130,122 @@ Connector (edge) example:
<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 (~1800 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens
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
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery
If edit_diagram fails with "pattern not found":
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
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
@@ -177,135 +295,6 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
\`\`\`
`
// Style instructions - only included when minimalStyle is false
const STYLE_INSTRUCTIONS = `
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
`
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
const MINIMAL_STYLE_INSTRUCTION = `
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
### No Styling - Plain Black/White Only
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
- NO color attributes (no hex colors like #ff69b4)
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
- IGNORE all color/style examples below
### Container/Group Shapes - MUST be Transparent
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
- This prevents containers from covering child elements
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
### Focus on Layout Quality
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
- SPACING: Minimum 50px gap between all elements
- NO OVERLAPS: Elements and edges must never overlap
- Follow ALL 7 Edge Routing Rules for arrow positioning
- Use waypoints to route edges AROUND obstacles
- Use different exitY/entryY values for multiple edges between same nodes
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
2. All mxCell elements must be siblings - never nested inside other mxCell elements
3. Every mxCell needs a unique id attribute (start from "2")
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
5. Edge source/target attributes must reference existing cell IDs
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
<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>
\`\`\`
### append_diagram Details
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
**CRITICAL RULES:**
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
### edit_diagram Details
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
**Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. Only cell_id is needed.
**Input Format:**
\`\`\`json
{
"operations": [
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"type": "delete", "cell_id": "5"}
]
}
\`\`\`
**Examples:**
Change label:
\`\`\`json
{"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Add new shape:
\`\`\`json
{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Delete cell:
\`\`\`json
{"operations": [{"type": "delete", "cell_id": "5"}]}
\`\`\`
**Error Recovery:**
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
@@ -358,16 +347,12 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
]
/**
* Get the appropriate system prompt based on the model ID and style preference
* 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
* @param minimalStyle - If true, removes style instructions to save tokens
* @returns The system prompt string
*/
export function getSystemPrompt(
modelId?: string,
minimalStyle?: boolean,
): string {
export function getSystemPrompt(modelId?: string): string {
const modelName = modelId || "AI"
let prompt: string
@@ -388,15 +373,5 @@ export function getSystemPrompt(
prompt = DEFAULT_SYSTEM_PROMPT
}
// Add style instructions based on preference
// Minimal style: prepend instruction at START (more prominent)
// Normal style: append at end
if (minimalStyle) {
console.log(`[System Prompt] Minimal style mode ENABLED`)
prompt = MINIMAL_STYLE_INSTRUCTION + prompt
} else {
prompt += STYLE_INSTRUCTIONS
}
return prompt.replace("{{MODEL_NAME}}", modelName)
}

View File

@@ -1,39 +0,0 @@
/**
* Token counting utilities using js-tiktoken
*
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
* This is a pure JavaScript implementation, no WASM required.
*/
import { encodingForModel } from "js-tiktoken"
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
const encoder = encodingForModel("gpt-4o")
/**
* Count the number of tokens in a text string
* @param text - The text to count tokens for
* @returns The number of tokens
*/
export function countTextTokens(text: string): number {
return encoder.encode(text).length
}
/**
* Get token counts for the system prompts
* Useful for debugging and optimizing prompt sizes
* @returns Object with token counts for default and extended prompts
*/
export function getSystemPromptTokenCounts(): {
default: number
extended: number
additions: number
} {
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
return {
default: defaultTokens,
extended: extendedTokens,
additions: extendedTokens - defaultTokens,
}
}

View File

@@ -1,110 +0,0 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import {
extractPdfText,
extractTextFileContent,
isPdfFile,
isTextFile,
MAX_EXTRACTED_CHARS,
} from "@/lib/pdf-utils"
export interface FileData {
text: string
charCount: number
isExtracting: boolean
}
/**
* Hook for processing file uploads, especially PDFs and text files.
* Handles text extraction, character limit validation, and cleanup.
*/
export function useFileProcessor() {
const [files, setFiles] = useState<File[]>([])
const [pdfData, setPdfData] = useState<Map<File, FileData>>(new Map())
const handleFileChange = async (newFiles: File[]) => {
setFiles(newFiles)
// Extract text immediately for new PDF/text files
for (const file of newFiles) {
const needsExtraction =
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)
if (needsExtraction) {
// Mark as extracting
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text: "",
charCount: 0,
isExtracting: true,
})
return next
})
// Extract text asynchronously
try {
let text: string
if (isPdfFile(file)) {
text = await extractPdfText(file)
} else {
text = await extractTextFileContent(file)
}
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
// Remove the file from the list
setFiles((prev) => prev.filter((f) => f !== file))
continue
}
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text,
charCount: text.length,
isExtracting: false,
})
return next
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
}
}
}
// Clean up pdfData for removed files
setPdfData((prev) => {
const next = new Map(prev)
for (const key of prev.keys()) {
if (!newFiles.includes(key)) {
next.delete(key)
}
}
return next
})
}
return {
files,
pdfData,
handleFileChange,
setFiles, // Export for external control (e.g., clearing files)
}
}

View File

@@ -1,247 +0,0 @@
"use client"
import { useCallback, useMemo } from "react"
import { toast } from "sonner"
import { QuotaLimitToast } from "@/components/quota-limit-toast"
import { STORAGE_KEYS } from "@/lib/storage"
export interface QuotaConfig {
dailyRequestLimit: number
dailyTokenLimit: number
tpmLimit: number
}
export interface QuotaCheckResult {
allowed: boolean
remaining: number
used: number
}
/**
* Hook for managing request/token quotas and rate limiting.
* Handles three types of limits:
* - Daily request limit
* - Daily token limit
* - Tokens per minute (TPM) rate limit
*
* Users with their own API key bypass all limits.
*/
export function useQuotaManager(config: QuotaConfig): {
hasOwnApiKey: () => boolean
checkDailyLimit: () => QuotaCheckResult
checkTokenLimit: () => QuotaCheckResult
checkTPMLimit: () => QuotaCheckResult
incrementRequestCount: () => void
incrementTokenCount: (tokens: number) => void
incrementTPMCount: (tokens: number) => void
showQuotaLimitToast: () => void
showTokenLimitToast: (used: number) => void
showTPMLimitToast: () => void
} {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
// Check if user has their own API key configured (bypass limits)
const hasOwnApiKey = useCallback((): boolean => {
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
const apiKey = localStorage.getItem(STORAGE_KEYS.aiApiKey)
return !!(provider && apiKey)
}, [])
// Generic helper: Parse count from localStorage with NaN guard
const parseStorageCount = (key: string): number => {
const count = parseInt(localStorage.getItem(key) || "0", 10)
return Number.isNaN(count) ? 0 : count
}
// Generic helper: Create quota checker factory
const createQuotaChecker = useCallback(
(
getTimeKey: () => string,
timeStorageKey: string,
countStorageKey: string,
limit: number,
) => {
return (): QuotaCheckResult => {
if (hasOwnApiKey())
return { allowed: true, remaining: -1, used: 0 }
if (limit <= 0) return { allowed: true, remaining: -1, used: 0 }
const currentTime = getTimeKey()
const storedTime = localStorage.getItem(timeStorageKey)
let count = parseStorageCount(countStorageKey)
if (storedTime !== currentTime) {
count = 0
localStorage.setItem(timeStorageKey, currentTime)
localStorage.setItem(countStorageKey, "0")
}
return {
allowed: count < limit,
remaining: limit - count,
used: count,
}
}
},
[hasOwnApiKey],
)
// Generic helper: Create quota incrementer factory
const createQuotaIncrementer = useCallback(
(
getTimeKey: () => string,
timeStorageKey: string,
countStorageKey: string,
validateInput: boolean = false,
) => {
return (tokens: number = 1): void => {
if (validateInput && (!Number.isFinite(tokens) || tokens <= 0))
return
const currentTime = getTimeKey()
const storedTime = localStorage.getItem(timeStorageKey)
let count = parseStorageCount(countStorageKey)
if (storedTime !== currentTime) {
count = 0
localStorage.setItem(timeStorageKey, currentTime)
}
localStorage.setItem(countStorageKey, String(count + tokens))
}
},
[],
)
// Check daily request limit
const checkDailyLimit = useMemo(
() =>
createQuotaChecker(
() => new Date().toDateString(),
STORAGE_KEYS.requestDate,
STORAGE_KEYS.requestCount,
dailyRequestLimit,
),
[createQuotaChecker, dailyRequestLimit],
)
// Increment request count
const incrementRequestCount = useMemo(
() =>
createQuotaIncrementer(
() => new Date().toDateString(),
STORAGE_KEYS.requestDate,
STORAGE_KEYS.requestCount,
false,
),
[createQuotaIncrementer],
)
// Show quota limit toast (request-based)
const showQuotaLimitToast = useCallback(() => {
toast.custom(
(t) => (
<QuotaLimitToast
used={dailyRequestLimit}
limit={dailyRequestLimit}
onDismiss={() => toast.dismiss(t)}
/>
),
{ duration: 15000 },
)
}, [dailyRequestLimit])
// Check daily token limit
const checkTokenLimit = useMemo(
() =>
createQuotaChecker(
() => new Date().toDateString(),
STORAGE_KEYS.tokenDate,
STORAGE_KEYS.tokenCount,
dailyTokenLimit,
),
[createQuotaChecker, dailyTokenLimit],
)
// Increment token count
const incrementTokenCount = useMemo(
() =>
createQuotaIncrementer(
() => new Date().toDateString(),
STORAGE_KEYS.tokenDate,
STORAGE_KEYS.tokenCount,
true, // Validate input tokens
),
[createQuotaIncrementer],
)
// Show token limit toast
const showTokenLimitToast = useCallback(
(used: number) => {
toast.custom(
(t) => (
<QuotaLimitToast
type="token"
used={used}
limit={dailyTokenLimit}
onDismiss={() => toast.dismiss(t)}
/>
),
{ duration: 15000 },
)
},
[dailyTokenLimit],
)
// Check TPM (tokens per minute) limit
const checkTPMLimit = useMemo(
() =>
createQuotaChecker(
() => Math.floor(Date.now() / 60000).toString(),
STORAGE_KEYS.tpmMinute,
STORAGE_KEYS.tpmCount,
tpmLimit,
),
[createQuotaChecker, tpmLimit],
)
// Increment TPM count
const incrementTPMCount = useMemo(
() =>
createQuotaIncrementer(
() => Math.floor(Date.now() / 60000).toString(),
STORAGE_KEYS.tpmMinute,
STORAGE_KEYS.tpmCount,
true, // Validate input tokens
),
[createQuotaIncrementer],
)
// Show TPM limit toast
const showTPMLimitToast = useCallback(() => {
const limitDisplay =
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
toast.error(
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
{ duration: 8000 },
)
}, [tpmLimit])
return {
// Check functions
hasOwnApiKey,
checkDailyLimit,
checkTokenLimit,
checkTPMLimit,
// Increment functions
incrementRequestCount,
incrementTokenCount,
incrementTPMCount,
// Toast functions
showQuotaLimitToast,
showTokenLimitToast,
showTPMLimitToast,
}
}

File diff suppressed because it is too large Load Diff

712
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.4.3",
"version": "0.3.0",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -13,11 +13,10 @@
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/amazon-bedrock": "^3.0.62",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107",
@@ -29,7 +28,6 @@
"@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.3",
@@ -37,23 +35,20 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@vercel/analytics": "^1.5.0",
"@xmldom/xmldom": "^0.9.8",
"ai": "^5.0.89",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-tiktoken": "^1.0.21",
"jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0",
"motion": "^12.23.25",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
@@ -62,7 +57,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"unpdf": "^1.4.0",
"zod": "^4.1.12"
},
"lint-staged": {
@@ -72,7 +66,6 @@
]
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",

View File

@@ -1,162 +0,0 @@
# Next AI Draw.io MCP Server
MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**.
**Self-contained** - includes an embedded HTTP server, no external dependencies required.
## Quick Start
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
## Installation
### Claude Desktop
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### VS Code
Add to your VS Code settings (`.vscode/mcp.json` in workspace or user settings):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Cursor
Add to Cursor MCP config (`~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
### Other MCP Clients
Use the standard MCP configuration with:
- **Command**: `npx`
- **Args**: `["@next-ai-drawio/mcp-server@latest"]`
## Usage
1. Restart your MCP client after updating config
2. Ask the AI to create a diagram:
> "Create a flowchart showing user authentication with login, MFA, and session management"
3. The diagram appears in your browser in real-time!
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `display_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
## How It Works
```
┌─────────────────┐ stdio ┌─────────────────┐
│ Claude Desktop │ <───────────> │ MCP Server │
│ (AI Agent) │ │ (this package) │
└─────────────────┘ └────────┬────────┘
┌────────▼────────┐
│ Embedded HTTP │
│ Server (:6002) │
└────────┬────────┘
┌────────▼────────┐
│ User's Browser │
│ (draw.io embed) │
└─────────────────┘
```
1. **MCP Server** receives tool calls from Claude via stdio
2. **Embedded HTTP Server** serves the draw.io UI and handles state
3. **Browser** shows real-time diagram updates via polling
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
## Troubleshooting
### Port already in use
If port 6002 is in use, the server will automatically try the next available port (up to 6020).
Or set a custom port:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": { "PORT": "6003" }
}
}
}
```
### "No active session"
Call `start_session` first to open the browser window.
### Browser not updating
Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.
## License
Apache-2.0

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +0,0 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.2",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
"bin": {
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"prepublishOnly": "npm run build"
},
"keywords": [
"mcp",
"drawio",
"diagram",
"ai",
"claude",
"model-context-protocol"
],
"author": "Biki-dev",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/Biki-dev/next-ai-draw-io",
"directory": "packages/mcp-server"
},
"homepage": "https://next-ai-drawio.jiang.jp",
"bugs": {
"url": "https://github.com/Biki-dev/next-ai-draw-io/issues"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^10.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^20",
"tsx": "^4.19.0",
"typescript": "^5"
},
"engines": {
"node": ">=18"
},
"files": [
"dist"
]
}

View File

@@ -1,219 +0,0 @@
/**
* ID-based diagram operations
* Copied from lib/utils.ts to avoid cross-package imports
*/
export interface DiagramOperation {
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface OperationError {
type: "update" | "add" | "delete"
cellId: string
message: string
}
export interface ApplyOperationsResult {
result: string
errors: OperationError[]
}
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
*/
export function applyDiagramOperations(
xmlContent: string,
operations: DiagramOperation[],
): ApplyOperationsResult {
const errors: OperationError[] = []
// Parse the XML
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Check for parse errors
const parseError = doc.querySelector("parsererror")
if (parseError) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `XML parse error: ${parseError.textContent}`,
},
],
}
}
// Find the root element (inside mxGraphModel)
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
}
// Build a map of cell IDs to elements
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
})
// Process each operation
for (const op of operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
if (!op.new_xml) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml is required for update operation",
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and replace the node
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
// Update the map with the new element
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
// Check if ID already exists
if (cellMap.has(op.cell_id)) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`,
})
continue
}
if (!op.new_xml) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml is required for add operation",
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
)
}
// Remove the node
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}
// Serialize back to string
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
}

View File

@@ -1,384 +0,0 @@
/**
* Embedded HTTP Server for MCP
*
* Serves a static HTML page with draw.io embed and handles state sync.
* This eliminates the need for an external Next.js app.
*/
import http from "node:http"
import { log } from "./logger.js"
interface SessionState {
xml: string
version: number
lastUpdated: Date
}
// In-memory state store (shared with MCP server in same process)
export const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null
let serverPort: number = 6002
const MAX_PORT = 6020 // Don't retry beyond this port
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
/**
* Get state for a session
*/
export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
/**
* Set state for a session
*/
export function setState(sessionId: string, xml: string): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
stateStore.set(sessionId, {
xml,
version: newVersion,
lastUpdated: new Date(),
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Start the embedded HTTP server
*/
export function startHttpServer(port: number = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
resolve(serverPort)
return
}
serverPort = port
server = http.createServer(handleRequest)
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (port >= MAX_PORT) {
reject(
new Error(
`No available ports in range 6002-${MAX_PORT}`,
),
)
return
}
log.info(`Port ${port} in use, trying ${port + 1}`)
server = null
startHttpServer(port + 1)
.then(resolve)
.catch(reject)
} else {
reject(err)
}
})
server.listen(port, () => {
serverPort = port
log.info(`Embedded HTTP server running on http://localhost:${port}`)
resolve(port)
})
})
}
/**
* Stop the HTTP server
*/
export function stopHttpServer(): void {
if (server) {
server.close()
server = null
}
}
/**
* Clean up expired sessions
*/
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionId, state] of stateStore) {
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
stateStore.delete(sessionId)
log.info(`Cleaned up expired session: ${sessionId}`)
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
/**
* Get the current server port
*/
export function getServerPort(): number {
return serverPort
}
/**
* Handle HTTP requests
*/
function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
// CORS headers for local development
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
// Route handling
if (url.pathname === "/" || url.pathname === "/index.html") {
serveHtml(req, res, url)
} else if (
url.pathname === "/api/state" ||
url.pathname === "/api/mcp/state"
) {
handleStateApi(req, res, url)
} else if (
url.pathname === "/api/health" ||
url.pathname === "/api/mcp/health"
) {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ status: "ok", mcp: true }))
} else {
res.writeHead(404)
res.end("Not Found")
}
}
/**
* Serve the HTML page with draw.io embed
*/
function serveHtml(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
const sessionId = url.searchParams.get("mcp") || ""
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
}
/**
* Handle state API requests
*/
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
if (req.method === "GET") {
const sessionId = url.searchParams.get("sessionId")
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
xml: state?.xml || null,
version: state?.version || 0,
lastUpdated: state?.lastUpdated?.toISOString() || null,
}),
)
} else if (req.method === "POST") {
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, xml } = JSON.parse(body)
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const version = setState(sessionId, xml)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
} else {
res.writeHead(405)
res.end("Method Not Allowed")
}
}
/**
* Generate the HTML page with draw.io embed
*/
function getHtmlPage(sessionId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 8px 16px;
background: #1a1a2e;
color: #eee;
font-family: system-ui, sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
</style>
</head>
<body>
<div id="container">
<div id="header">
<div>
<strong>Draw.io MCP</strong>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0;
let isDrawioReady = false;
let pendingXml = null;
let lastLoadedXml = null;
// Listen for messages from draw.io
window.addEventListener('message', (event) => {
if (event.origin !== 'https://embed.diagrams.net') return;
try {
const msg = JSON.parse(event.data);
handleDrawioMessage(msg);
} catch (e) {
// Ignore non-JSON messages
}
});
function handleDrawioMessage(msg) {
if (msg.event === 'init') {
isDrawioReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
// Load pending XML if any
if (pendingXml) {
loadDiagram(pendingXml);
pendingXml = null;
}
} else if (msg.event === 'save') {
// User saved - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
} else if (msg.event === 'export') {
// Export completed
if (msg.data) {
pushState(msg.data);
}
} else if (msg.event === 'autosave') {
// Autosave - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
}
}
function loadDiagram(xml) {
if (!isDrawioReady) {
pendingXml = xml;
return;
}
lastLoadedXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
xml: xml,
autosave: 1
}), '*');
}
async function pushState(xml) {
if (!sessionId) return;
try {
const response = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml })
});
if (response.ok) {
const result = await response.json();
currentVersion = result.version;
lastLoadedXml = xml;
}
} catch (e) {
console.error('Failed to push state:', e);
}
}
async function pollState() {
if (!sessionId) return;
try {
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!response.ok) return;
const state = await response.json();
if (state.version && state.version > currentVersion && state.xml) {
currentVersion = state.version;
loadDiagram(state.xml);
}
} catch (e) {
console.error('Failed to poll state:', e);
}
}
// Start polling if we have a session
if (sessionId) {
pollState();
setInterval(pollState, 2000);
}
</script>
</body>
</html>`
}

View File

@@ -1,476 +0,0 @@
#!/usr/bin/env node
/**
* MCP Server for Next AI Draw.io
*
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
* draw.io diagrams with real-time browser preview.
*
* Uses an embedded HTTP server - no external dependencies required.
*/
// Setup DOM polyfill for Node.js (required for XML operations)
import { DOMParser } from "linkedom"
;(globalThis as any).DOMParser = DOMParser
// Create XMLSerializer polyfill using outerHTML
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) {
return node.outerHTML
}
if (node.documentElement) {
return node.documentElement.outerHTML
}
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import {
getServerPort,
getState,
setState,
startHttpServer,
} from "./http-server.js"
import { log } from "./logger.js"
// Server configuration
const config = {
port: parseInt(process.env.PORT || "6002"),
}
// Session state (single session for simplicity)
let currentSession: {
id: string
xml: string
version: number
} | null = null
// Create MCP server
const server = new McpServer({
name: "next-ai-drawio",
version: "0.1.2",
})
// Register prompt with workflow guidance
server.prompt(
"diagram-workflow",
"Guidelines for creating and editing draw.io diagrams",
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `# Draw.io Diagram Workflow Guidelines
## Creating a New Diagram
1. Call start_session to open the browser preview
2. Use display_diagram with complete mxGraphModel XML to create a new diagram
## Adding Elements to Existing Diagram
1. Use edit_diagram with "add" operation
2. Provide a unique cell_id and complete mxCell XML
3. No need to call get_diagram first - the server fetches latest state automatically
## Modifying or Deleting Existing Elements
1. FIRST call get_diagram to see current cell IDs and structure
2. THEN call edit_diagram with "update" or "delete" operations
3. For update, provide the cell_id and complete new mxCell XML
## Important Notes
- display_diagram REPLACES the entire diagram - only use for new diagrams
- edit_diagram PRESERVES user's manual changes (fetches browser state first)
- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`,
},
},
],
}),
)
// Tool: start_session
server.registerTool(
"start_session",
{
description:
"Start a new diagram session and open the browser for real-time preview. " +
"Starts an embedded server and opens a browser window with draw.io. " +
"The browser will show diagram updates as they happen.",
inputSchema: {},
},
async () => {
try {
// Start embedded HTTP server
const port = await startHttpServer(config.port)
// Create session
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
currentSession = {
id: sessionId,
xml: "",
version: 0,
}
// Open browser
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
await open(browserUrl)
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
return {
content: [
{
type: "text",
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("start_session failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: display_diagram
server.registerTool(
"display_diagram",
{
description:
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
"Use this for creating new diagrams from scratch. " +
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
"You should generate valid draw.io/mxGraph XML format.",
inputSchema: {
xml: z
.string()
.describe("The draw.io XML to display (mxGraphModel format)"),
},
},
async ({ xml }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
log.info(`Displaying diagram, ${xml.length} chars`)
// Update session state
currentSession.xml = xml
currentSession.version++
// Push to embedded server state
setState(currentSession.id, xml)
log.info(`Diagram displayed successfully`)
return {
content: [
{
type: "text",
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("display_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: edit_diagram
server.registerTool(
"edit_diagram",
{
description:
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
"IMPORTANT workflow:\n" +
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" +
"Operations:\n" +
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
inputSchema: {
operations: z
.array(
z.object({
type: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z.string().describe("The id of the mxCell"),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
},
},
async ({ operations }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
log.info("Fetched latest diagram state from browser")
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.",
},
],
isError: true,
}
}
log.info(`Editing diagram with ${operations.length} operation(s)`)
// Apply operations
const { result, errors } = applyDiagramOperations(
currentSession.xml,
operations as DiagramOperation[],
)
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${e.type} ${e.cellId}: ${e.message}`)
.join("\n")
log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)
}
// Update state
currentSession.xml = result
currentSession.version++
// Push to embedded server
setState(currentSession.id, result)
log.info(`Diagram edited successfully`)
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
const errorMsg =
errors.length > 0
? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}`
: ""
return {
content: [
{
type: "text",
text: successMsg + errorMsg,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("edit_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: get_diagram
server.registerTool(
"get_diagram",
{
description:
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
"Call this BEFORE edit_diagram if you need to update or delete existing elements, " +
"so you can see the current cell IDs and structure.",
},
async () => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "No diagram exists yet. Use display_diagram to create one.",
},
],
}
}
return {
content: [
{
type: "text",
text: `Current diagram XML:\n\n${currentSession.xml}`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("get_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: export_diagram
server.registerTool(
"export_diagram",
{
description: "Export the current diagram to a .drawio file.",
inputSchema: {
path: z
.string()
.describe(
"File path to save the diagram (e.g., ./diagram.drawio)",
),
},
},
async ({ path }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "Error: No diagram to export. Please create a diagram first.",
},
],
isError: true,
}
}
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
let filePath = path
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("export_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Start the MCP server
async function main() {
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
const transport = new StdioServerTransport()
await server.connect(transport)
log.info("MCP server running on stdio")
}
main().catch((error) => {
log.error("Fatal error:", error)
process.exit(1)
})

View File

@@ -1,24 +0,0 @@
/**
* Logger for MCP server
*
* CRITICAL: MCP servers communicate via STDIO (stdin/stdout).
* Using console.log() will corrupt the JSON-RPC protocol messages.
* ALL logging MUST use console.error() which writes to stderr.
*/
export const log = {
info: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [INFO] ${msg}`, ...args)
},
error: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
},
debug: (msg: string, ...args: unknown[]) => {
if (process.env.DEBUG === "true") {
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
}
},
warn: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [WARN] ${msg}`, ...args)
},
}

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,65 +0,0 @@
Here is an extended summary of the paper **"Chain-of-Thought Prompting Elicits Reasoning in Large Language Models"** by Jason Wei, et al. This detailed overview covers the background, methodology, extensive experimental results, emergent properties, and qualitative analysis found in the study.
### **1. Introduction and Motivation**
The paper addresses a significant limitation in Large Language Models (LLMs): while scaling up model size (increasing parameters) has revolutionized performance on standard NLP tasks, it has not proven sufficient for challenging logical tasks such as arithmetic, commonsense, and symbolic reasoning.
Traditional techniques to solve these problems fell into two camps:
1. **Finetuning:** Training models manually with large datasets of explanations (expensive and task-specific).
2. **Standard Few-Shot Prompting:** Providing input-output pairs (e.g., Question $\rightarrow$ Answer) without explaining *how* the answer was derived. This often fails on multi-step problems.
The authors introduce **Chain-of-Thought (CoT) Prompting**, a simple method that combines the strengths of both approaches. It leverages the model's existing capabilities to generate natural language rationales without requiring any model parameter updates (finetuning).
### **2. Methodology: What is Chain-of-Thought?**
The core innovation is changing the structure of the "exemplars" (the few-shot examples included in the prompt).
* **Standard Prompting:** The model is shown a question and an immediate answer.
* *Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many now?*
* *A: 11.*
* **Chain-of-Thought Prompting:** The model is shown a question, followed by a series of intermediate natural language reasoning steps that lead to the answer.
* *A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11.*
By interacting with the model using this format, the LLM learns to generate its own "thought process" for new, unseen questions. This allows the model to decompose complex problems into manageable intermediate steps.
### **3. Experimental Setup**
The researchers evaluated CoT prompting on several large language models, including **GPT-3 (175B)**, **LaMDA (137B)**, **PaLM (540B)**, **UL2 (20B)**, and **Codex**. They tested across three distinct domains of reasoning:
* **Arithmetic Reasoning:** Using benchmarks like **GSM8K** (math word problems), **SVAMP**, **ASDiv**, **AQuA**, and **MAWPS**.
* **Commonsense Reasoning:** Using datasets like **CSQA**, **StrategyQA**, **Date Understanding**, and **Sports Understanding**.
* **Symbolic Reasoning:** Using tasks like **Last Letter Concatenation** and **Coin Flip** tracking (determining if a coin is heads or tails after a sequence of flips).
### **4. Key Findings and Results**
#### **Arithmetic Reasoning**
The results on math word problems were striking. Standard prompting struggled significantly, often exhibiting a flat scaling curve (performance didn't improve much even as models got bigger).
* **Performance Jump:** On the difficult **GSM8K** benchmark, **PaLM 540B** with CoT prompting achieved **56.9%** accuracy, compared to just 17.9% with standard prompting.
* **Surpassing State-of-the-Art:** PaLM 540B with CoT outperformed a previously finetuned GPT-3 model (55%), establishing a new state-of-the-art without needing a training set.
* **Calculator Integration:** The authors noted that some errors were simple calculation mistakes in otherwise correct logic. By hooking the CoT output into an external Python calculator, accuracy on GSM8K rose further to **58.6%**.
#### **Commonsense Reasoning**
CoT prompting improved performance on tasks requiring background knowledge and physical intuition.
* **StrategyQA:** PaLM 540B achieved **75.6%** accuracy via CoT, beating the prior state-of-the-art (69.4%).
* **Sports Understanding:** The model achieved **95.4%** accuracy, surpassing the performance of an unaided sports enthusiast (84%).
* The gains were minimal on CSQA, likely because many questions in that dataset did not require multi-step logic.
#### **Symbolic Reasoning and Generalization**
A unique strength of CoT was enabling **Out-of-Domain (OOD) Generalization**.
* In the **Coin Flip** task, the models were given examples with only 2 flips. However, using CoT, the models could successfully track coins flipped 3 or 4 times.
* Standard prompting failed completely on these longer sequences, while CoT allowed the model to repeat the logical steps as many times as necessary to reach the solution.
### **5. Emergent Ability of Scale**
One of the paper's most critical insights is that CoT reasoning is an **emergent ability** that depends on model size.
* **Small Models (<10B parameters):** CoT prompting provided **no benefit** and often hurt performance. Small models produced fluent but illogical chains of thought (hallucinations) or suffered from repetition.
* **Large Models (~100B+ parameters):** The ability to reason sequentially emerges at this scale. The performance gains from CoT are negligible for small models but increase dramatically for models like GPT-3 (175B) and PaLM (540B).
### **6. Why Does It Work? (Ablation Studies)**
To ensure the improvement was due to the reasoning steps and not other factors, the authors conducted three specific ablations:
1. **Equation Only:** They prompted the model to output just the math equation without words. This performed worse than CoT, suggesting that natural language helps the model "understand" the question semantics.
2. **Variable Compute:** They prompted the model to output dots (...) to consume compute time before answering. This yielded no improvement, proving that the *content* of the reasoning steps matters, not just the extra tokens.
3. **Reasoning After Answer:** They asked the model to give the answer first, then the explanation. This performed about the same as the baseline, proving that the chain of thought must come *before* the answer to guide the model's inference process.
### **7. Error Analysis and Robustness**
The authors manually analyzed errors made by the models.
* **Error Types:** In math problems, errors were categorized as **Semantic Understanding** (misunderstanding the question), **One-Step Missing** (skipping a logical step), or **Calculation Errors**.
* **Impact of Scale:** Scaling from PaLM 62B to PaLM 540B significantly reduced semantic and missing-step errors, confirming that larger models are better at logic, not just memorization.
* **Robustness:** The method proved robust to different annotators (different people writing the prompts) and different specific examples, though, like all prompting, different prompt styles did result in some variance.
### **Conclusion**
The paper establishes Chain-of-Thought prompting as a powerful paradigm for unlocking the reasoning potential of Large Language Models. By simply asking the model to "show its work," researchers can elicit complex logical behaviors that were previously thought to require specialized architectures or extensive finetuning. The work highlights that reasoning is an emergent capability of sufficiently large language models.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="36" viewBox="0 0 140 36">
<rect width="140" height="36" rx="8" fill="#6366f1"/>
<text x="70" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="15" font-weight="600" fill="white" text-anchor="middle">🚀 Live Demo</text>
</svg>

Before

Width:  |  Height:  |  Size: 340 B

View File

@@ -1,251 +0,0 @@
/**
* Simple test script for applyDiagramOperations function
* Run with: node scripts/test-diagram-operations.mjs
*/
import { JSDOM } from "jsdom"
// Set up DOMParser for Node.js environment
const dom = new JSDOM()
globalThis.DOMParser = dom.window.DOMParser
globalThis.XMLSerializer = dom.window.XMLSerializer
// Import the function (we'll inline it since it's not ESM exported)
function applyDiagramOperations(xmlContent, operations) {
const errors = []
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
const parseError = doc.querySelector("parsererror")
if (parseError) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}` }],
}
}
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: "Could not find <root> element in XML" }],
}
}
const cellMap = new Map()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
})
for (const op of operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
if (!op.new_xml) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
if (cellMap.has(op.cell_id)) {
errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists` })
continue
}
if (!op.new_xml) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
}
// Test data
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
<mxfile>
<diagram>
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Box A" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="Box B" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="300" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
let passed = 0
let failed = 0
function test(name, fn) {
try {
fn()
console.log(`${name}`)
passed++
} catch (e) {
console.log(`${name}`)
console.log(` Error: ${e.message}`)
failed++
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || "Assertion failed")
}
// Tests
test("Update operation changes cell value", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated Box A"'), "Updated value should be in result")
assert(!result.includes('value="Box A"'), "Old value should not be in result")
})
test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "999", new_xml: '<mxCell id="999" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "2", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Add operation creates new cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('id="new1"'), "New cell should be in result")
assert(result.includes('value="New Box"'), "New cell value should be in result")
})
test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "2", new_xml: '<mxCell id="2" value="Duplicate"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("already exists"), "Error should mention already exists")
})
test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "new1", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "3" }])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(!result.includes('id="3"'), "Deleted cell should not be in result")
assert(result.includes('id="2"'), "Other cells should remain")
})
test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "999" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Multiple operations in sequence", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{ type: "delete", cell_id: "3" },
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated"'), "Updated value should be present")
assert(result.includes('id="new1"'), "Added cell should be present")
assert(!result.includes('id="3"'), "Deleted cell should not be present")
})
test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
})
test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("root"), "Error should mention root element")
})
// Summary
console.log(`\n${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)

View File

@@ -29,5 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", "packages"]
"exclude": ["node_modules"]
}

View File

@@ -1,12 +0,0 @@
{
"functions": {
"app/api/chat/route.ts": {
"memory": 512,
"maxDuration": 120
},
"app/api/**/route.ts": {
"memory": 256,
"maxDuration": 10
}
}
}