Compare commits

..
Author SHA1 Message Date
dayuan.jiang 011b883cdb feat(i18n): add validation strings for ValidationCard component
- Add validation section to en.json, zh.json, ja.json dictionaries
- Update ValidationCard to use useDictionary hook
- Replace all hardcoded English strings with i18n keys
2026-01-20 20:43:35 +09:00
dayuan.jiang fd9302e736 fix: return empty string for valid result with no issues in formatValidationFeedback 2026-01-20 20:04:29 +09:00
dayuan.jiang fa06c61538 fix: resolve TypeScript errors in electron-standalone
- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking
2026-01-20 19:49:12 +09:00
dayuan.jiang 60994d281e fix: improve VLM validation with bug fixes and i18n
- Fix race condition in pendingValidationRef (reject previous pending validation)
- Fix response format consistency (use streaming for all responses)
- Remove dead code (unused lastRequestRef and ValidationRequest interface)
- Consolidate duplicate types (re-export from validation-schema.ts)
- Add 'success_with_warnings' status for valid diagrams with warnings
- Fix tool card auto-collapse (only collapse once, respect user toggle)
- Set VLM validation default to disabled
- Add i18n support for diagram validation settings (en/zh/ja)
- Mark feature as experimental in settings UI
2026-01-20 19:45:26 +09:00
Jinze Yu 6640272f90 fix(validation): add aria-hidden to icons to prevent duplicate ID warning 2026-01-19 18:21:56 +09:00
Jinze Yu 86560e0fc0 fix(validation): use 'Valid' instead of 'Complete' for validation success
Change ValidationCard success label from 'Complete' to 'Valid' to avoid
conflicting with ToolCallCard's 'Complete' badge in E2E tests. This fixes
the diagram-generation E2E test that expects a specific count of 'Complete'
badges.
2026-01-19 10:41:58 +09:00
Jinze Yu bb6c7c11ce Merge origin/main into features/validate-diagram-with-vlm 2026-01-19 01:35:34 +09:00
Jinze Yu 6b0296a97f fix(validation): extract schema to shared file for client/server compatibility
Move ValidationResultSchema to lib/validation-schema.ts to avoid importing
server-side modules (ai-providers) into client-side code. This fixes the
Turbopack build error caused by the hook importing from the API route.
2026-01-19 01:30:51 +09:00
Jinze Yu 85513b8c00 refactor(validation): use AI SDK experimental_useObject hook instead of raw fetch
- Change API endpoint from generateObject to streamObject for useObject compatibility
- Create useValidateDiagram hook using AI SDK's experimental_useObject for reactive validation
- Update useDiagramToolHandlers to accept validation function as parameter
- Update chat-panel to use new useValidateDiagram hook
- Remove validateRenderedDiagram function from lib/diagram-validator.ts (now in hook)
- Export ValidationResultSchema from API route for client-side use
2026-01-19 01:10:31 +09:00
Jinze Yu 24270e2622 refactor(validation): use AI SDK structured outputs and address review feedback
- Replace generateText + manual JSON parsing with generateObject and Zod schema
  for type-safe structured validation output
- Use AbortSignal.timeout() instead of Promise.race for cleaner timeout handling
- Add timeout validation with minimum 1000ms to handle malformed env values
- Remove unused xml parameter from validateRenderedDiagram API
- Remove parseValidationResponse function (now handled by schema)
- Clear validationStates on session switch and new chat to prevent memory leak
- Update 100ms render delay comment to clarify best-effort heuristic
- Remove unused useEffect import from ValidationCard
- Fix optional chaining lint warning in ValidationCard
- Add unit tests for formatValidationFeedback function
2026-01-19 00:47:49 +09:00
Jinze Yu 07c9e7d758 Merge remote-tracking branch 'origin/main' into features/validate-diagram-with-vlm 2026-01-17 20:59:51 +09:00
Jinze Yu ee4c0149f1 [Feature] Add VLM-based diagram validation
Add automatic VLM (Vision Language Model) validation after display_diagram
tool execution. The system captures a screenshot of the rendered diagram,
sends it to a VLM for visual analysis, and uses feedback to improve
diagram quality through the existing retry mechanism.

Changes:
- Add /api/validate-diagram endpoint for VLM validation
- Add diagram-validator.ts for client-side validation orchestration
- Add validation-prompts.ts for VLM system prompts
- Add ValidationCard component to display validation status in chat
- Add PNG capture functionality to diagram context
- Integrate validation into tool handlers with retry support (max 3)
- Add "Improve with Suggestions" button for manual regeneration
- Add settings toggle to enable/disable VLM validation
- Add getValidationModel() helper in ai-providers.ts
2026-01-17 17:16:12 +09:00
297 changed files with 23933 additions and 53392 deletions
-19
View File
@@ -47,10 +47,6 @@ To run tests with UI mode:
npx playwright test --ui
```
## Before You Start
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
## Pull Requests
1. Create a feature branch
@@ -61,21 +57,6 @@ For **significant changes** (new features, architecture changes, large refactors
CI will run the full test suite on your PR.
## Using AI Tools
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
1. **Review the code** — understand what was generated, don't just commit blindly
2. **Write a PR description** — explain what changed and why
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
## Code Review
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
- **Valid suggestions**: Please address them in your code.
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used.
-5
View File
@@ -33,11 +33,6 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
}
],
"vulnerabilityAlerts": {
+1 -3
View File
@@ -23,9 +23,7 @@ jobs:
node-version: '24'
- name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
+2
View File
@@ -40,3 +40,5 @@ jobs:
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev
+1 -2
View File
@@ -58,8 +58,6 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -91,3 +89,4 @@ jobs:
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
+1 -9
View File
@@ -34,13 +34,9 @@ jobs:
node-version: 24
cache: "npm"
# The download script reads .env files with @next/env
- name: Install dependencies
run: npm install
- name: Download draw.io static files for offline use
run: node scripts/fetch-drawio.mjs
- name: Build and publish
run: npm run dist:${{ matrix.platform }}
env:
@@ -61,13 +57,9 @@ jobs:
node-version: 24
cache: "npm"
# The download script reads .env files with @next/env
- name: Install dependencies
run: npm install
- name: Download draw.io static files for offline use
run: node scripts/fetch-drawio.mjs
# Build WITHOUT publishing
- name: Build Windows app
run: npm run dist:win:build
@@ -88,7 +80,7 @@ jobs:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io'
signing-policy-slug: 'release-signing'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
-71
View File
@@ -1,71 +0,0 @@
name: Publish MCP Server
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
# skips silently if the package.json version is already on npm — so a
# release is just "bump the version in a PR and merge".
on:
push:
branches:
- main
paths:
- "packages/mcp-server/**"
workflow_dispatch:
permissions:
contents: read
id-token: write # OIDC token for npm trusted publishing
concurrency:
group: publish-mcp
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/mcp-server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "npm"
cache-dependency-path: packages/mcp-server/package-lock.json
registry-url: "https://registry.npmjs.org"
# Trusted publishing requires npm >= 11.5.1
- name: Update npm
run: npm install -g npm@latest
- name: Check if version is already published
id: version
run: |
LOCAL=$(node -p "require('./package.json').version")
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
echo "Version ${LOCAL} already on npm - nothing to publish"
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version ${LOCAL} not on npm - publishing"
echo "publish=true" >> "$GITHUB_OUTPUT"
fi
- name: Install dependencies
if: steps.version.outputs.publish == 'true'
run: npm ci
- name: Test
if: steps.version.outputs.publish == 'true'
run: npm test
- name: Build and check package contents
if: steps.version.outputs.publish == 'true'
run: npm run build && npm run check-package
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish
-14
View File
@@ -28,20 +28,6 @@ jobs:
- name: Run unit tests
run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
# Tests run from src/, so check the built npm package separately
- name: Build MCP server and check package contents
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e:
name: E2E Tests
runs-on: ubuntu-latest
-9
View File
@@ -56,8 +56,6 @@ push-via-ec2.sh
/dist-electron/
/release/
/electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg
*.exe
*.AppImage
@@ -72,10 +70,3 @@ CLAUDE.md
.edgeone
opencode.json
ai-models.json
# local backups
*.bak
.gstack/
# admin panel settings (contains secrets)
data/
+2 -12
View File
@@ -9,7 +9,6 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install dependencies
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN npm install
# Stage 2: Build application
@@ -23,9 +22,8 @@ COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time argument for an external draw.io URL. Leave empty to use the
# copy bundled into public/drawio (downloaded by `npm run build`)
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=""
# 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-time argument to show About link and Notice icon
@@ -36,11 +34,6 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Control sponsorship and self-hosting messaging in quota notifications.
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
ARG NEXT_PUBLIC_SELFHOSTED=""
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
# Build Next.js application (standalone mode)
RUN npm run build
@@ -62,9 +55,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
+20 -49
View File
@@ -19,18 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
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.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
<p align="center">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
</picture>
</a>
</p>
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -42,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server](#mcp-server)
- [MCP Server (Preview)](#mcp-server-preview)
- [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
@@ -54,8 +43,6 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
@@ -76,24 +63,24 @@ Here are some example prompts and their generated diagrams:
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG Technique Diagram</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
<strong>GCP architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Authentication using React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
<strong>AWS architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Open Innovation</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
<strong>Azure architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Cat sketch</strong><br />
<strong>Cat sketch prompt</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
</td>
@@ -107,13 +94,14 @@ Here are some example prompts and their generated diagrams:
- **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.)
- **Versions and Undo**: Every AI change shows up in the chat as a version with a thumbnail. Compare it with the canvas, restore it, or undo it; Ctrl+Z on the canvas also takes back an AI change in one step. Shapes the AI just changed are highlighted.
- **Ask About a Selection**: Select shapes on the canvas and ask the AI to change just those.
- **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
## MCP Server
## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not be stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
@@ -139,13 +127,6 @@ Then ask Claude to create diagrams:
The diagram appears in your browser in real-time!
The MCP server includes most of the web app's drawing features:
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
- A screenshot tool, so the AI can check the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
## Getting Started
@@ -221,7 +202,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
## Multi-Provider Support
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock (default)
- OpenAI
- Anthropic
@@ -230,13 +211,11 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
All providers except AWS Bedrock and OpenRouter support custom endpoints.
@@ -245,13 +224,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
**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.
@@ -264,16 +237,14 @@ 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
- **draw.io**: The editor runs from a copy bundled into `public/drawio` (downloaded by `npm run dev` / `npm run build`), so the app can drive it directly
- **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.
## Support & Contact
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
+63 -97
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "关于 - Next AI Draw.io",
@@ -78,7 +78,7 @@ export default function AboutCN() {
<p>
好消息!感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -87,7 +87,7 @@ export default function AboutCN() {
</a>
的慷慨赞助,演示站点现已接入强大的{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
模型,图表生成效果更佳!点击链接注册即可领取{" "}
<span className="font-semibold text-amber-700">
@@ -97,23 +97,6 @@ export default function AboutCN() {
</p>
</div>
{/* Invite Poster */}
<div className="text-center mb-5">
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/volcengine-invite.png"
alt="火山引擎方舟 Coding Plan"
width={300}
height={400}
className="mx-auto rounded-lg"
/>
</a>
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -149,10 +132,8 @@ export default function AboutCN() {
:上传现有图表或图像,让AI自动复制和增强
</li>
<li>
<strong>版本与撤销</strong>
:AI
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
Ctrl+Z 也能撤回
<strong>图表历史记录</strong>
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
</li>
<li>
<strong>交互式聊天界面</strong>
@@ -177,106 +158,92 @@ export default function AboutCN() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50模型架构动画
动画Transformer连接器
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong>提示词:</strong> 给我一个带有
<strong>动画连接器</strong>的Transformer架构图。
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50模型架构图"
width={480}
height={360}
className="mx-auto"
/>
</div>
<Image
src="/animated_connectors.svg"
alt="带动画连接器的Transformer架构"
width={480}
height={360}
className="mx-auto"
/>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技术图
GCP架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong>提示词:</strong> 使用
<strong>GCP图标</strong>
生成一个GCP架构图。用户连接到托管在实例上的前端。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/gcp_demo.svg"
alt="GCP架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
React和AWS认证流程
AWS架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong>提示词:</strong> 使用
<strong>AWS图标</strong>
生成一个AWS架构图。用户连接到托管在实例上的前端。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="认证架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/aws_demo.svg"
alt="AWS架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
敏捷Scrum流程
Azure架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong>提示词:</strong> 使用
<strong>Azure图标</strong>
生成一个Azure架构图。用户连接到托管在实例上的前端。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="敏捷Scrum流程图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/azure_demo.svg"
alt="Azure架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
开放式创新
猫咪素描
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong>提示词:</strong>{" "}
给我画一只可爱的猫。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="开放式创新图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/cat_demo.svg"
alt="猫咪绘图"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
@@ -296,8 +263,7 @@ export default function AboutCN() {
):用于流式AI响应和多提供商支持
</li>
<li>
<strong>draw.io</strong>
:内置在应用里的编辑器,应用可以直接控制它
<strong>react-drawio</strong>:用于图表表示和操作
</li>
</ul>
<p className="text-gray-700 mt-4">
@@ -311,7 +277,7 @@ export default function AboutCN() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -346,7 +312,7 @@ export default function AboutCN() {
<p className="text-gray-700 mb-4 font-semibold">
特别感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+65 -80
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "概要 - Next AI Draw.io",
@@ -86,7 +86,7 @@ export default function AboutJA() {
<p>
朗報です!
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -95,7 +95,7 @@ export default function AboutJA() {
</a>
様のご支援により、デモサイトでは強力な{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
<span className="font-semibold text-amber-700">
@@ -138,10 +138,8 @@ export default function AboutJA() {
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
</li>
<li>
<strong>バージョンと取り消し</strong>
:AI
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
Ctrl+Z を押しても元に戻せます
<strong>ダイアグラム履歴</strong>
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
</li>
<li>
<strong>
@@ -170,106 +168,93 @@ export default function AboutJA() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50モデルアーキテクチャアニメーション
アニメーションTransformerコネクタ
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong>プロンプト:</strong>{" "}
<strong>アニメーションコネクタ</strong>
付きのTransformerアーキテクチャ図を作成してください。
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50モデルアーキテクチャ図"
width={480}
height={360}
className="mx-auto"
/>
</div>
<Image
src="/animated_connectors.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
width={480}
height={360}
className="mx-auto"
/>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技術ダイアグラム
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong>プロンプト:</strong>{" "}
<strong>GCPアイコン</strong>
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAGアーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/gcp_demo.svg"
alt="GCPアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ReactとAWSによる認証
AWSアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong>プロンプト:</strong>{" "}
<strong>AWSアイコン</strong>
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="認証アーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/aws_demo.svg"
alt="AWSアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
アジャイルスクラムプロセス
Azureアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong>プロンプト:</strong>{" "}
<strong>Azureアイコン</strong>
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="アジャイルスクラム図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/azure_demo.svg"
alt="Azureアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
オープンイノベーション
猫のスケッチ
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong>プロンプト:</strong>{" "}
かわいい猫を描いてください。
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="オープンイノベーション図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/cat_demo.svg"
alt="猫の絵"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
@@ -292,8 +277,8 @@ export default function AboutJA() {
):ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>draw.io</strong>
:アプリに同梱されたエディター。アプリから直接操作できます
<strong>react-drawio</strong>
:ダイアグラムの表現と操作
</li>
</ul>
<p className="text-gray-700 mt-4">
@@ -307,7 +292,7 @@ export default function AboutJA() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -342,7 +327,7 @@ export default function AboutJA() {
<p className="text-gray-700 mb-4 font-semibold">
デモサイトのAPIトークン使用を支援してくださった{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+69 -79
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "About - Next AI Draw.io",
@@ -87,7 +87,7 @@ export default function About() {
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -96,7 +96,7 @@ export default function About() {
</a>
, the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
@@ -150,10 +150,10 @@ export default function About() {
replicate and enhance them automatically
</li>
<li>
<strong>Versions and Undo</strong>: Every AI change
becomes a version in the chat that you can compare,
restore or undo; Ctrl+Z on the canvas takes it back
too
<strong>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</li>
<li>
<strong>Interactive Chat Interface</strong>:
@@ -182,106 +182,96 @@ export default function About() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated ResNet50 Model Architecture
Animated Transformer Connectors
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="Architecture diagram for ResNet50 model"
width={480}
height={360}
className="mx-auto"
/>
</div>
<Image
src="/animated_connectors.svg"
alt="Transformer Architecture with Animated Connectors"
width={480}
height={360}
className="mx-auto"
/>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG Technique Diagram
GCP Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong>Prompt:</strong> Generate a GCP
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/gcp_demo.svg"
alt="GCP Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Authentication using React and AWS
AWS Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong>Prompt:</strong> Generate an AWS
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="Authentication Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/aws_demo.svg"
alt="AWS Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Agile Scrum Process
Azure Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong>Prompt:</strong> Generate an Azure
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="Agile Scrum Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/azure_demo.svg"
alt="Azure Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Open Innovation
Cat Sketch
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong>Prompt:</strong> Draw a cute cat for
me.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="Open Innovation Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<Image
src="/cat_demo.svg"
alt="Cat Drawing"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
@@ -304,8 +294,8 @@ export default function About() {
and multi-provider support
</li>
<li>
<strong>draw.io</strong>: The editor, bundled with
the app so it can be driven directly
<strong>react-drawio</strong>: For diagram
representation and manipulation
</li>
</ul>
<p className="text-gray-700 mt-4">
@@ -321,7 +311,7 @@ export default function About() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -358,7 +348,7 @@ export default function About() {
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
-65
View File
@@ -1,65 +0,0 @@
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
export interface SettingState {
key: string
source: "file" | "env" | "default"
value: string | SecretValue | null
}
export type SettingsMap = Record<string, SettingState>
// Editable text of a saved setting; secrets have none (write-only)
export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
provider: ProviderName
models: string[]
isDefault: boolean
}
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
const res = await fetch(getApiEndpoint(path), {
...init,
headers: {
...init?.headers,
"x-admin-password": pw,
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Request failed (${res.status})`)
}
return data
}
-627
View File
@@ -1,627 +0,0 @@
import {
AlertCircle,
Check,
Loader2,
Plus,
Star,
Trash2,
X,
Zap,
} from "lucide-react"
import { useState } from "react"
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
import {
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
} from "./admin-shared"
import { SecretInput } from "./setting-field"
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
function ProviderDetail({
provider,
disabled,
password,
onUpdate,
onDelete,
}: {
provider: AdminProvider
disabled: boolean
password: string
onUpdate: (patch: Partial<AdminProvider>) => void
onDelete: () => void
}) {
const dict = useDictionary()
const [modelInput, setModelInput] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResults, setTestResults] = useState<
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
const addModel = (modelId: string) => {
const trimmed = modelId.trim()
if (!trimmed || provider.models.includes(trimmed)) return
onUpdate({ models: [...provider.models, trimmed] })
setModelInput("")
}
const testModel = async (modelId: string) => {
setTesting(modelId)
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
// EdgeOne's function also checks the access code
headers: {
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
...prev,
[modelId]: data.valid
? {
ok: true,
message: formatMessage(dict.admin.testOk, {
ms: data.responseTime,
}),
}
: {
ok: false,
message: data.error || dict.admin.testFailed,
},
}))
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelId]: {
ok: false,
message:
err instanceof Error
? err.message
: dict.admin.testFailed,
},
}))
} finally {
setTesting(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={provider.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{info.label}</h3>
<p className="text-xs text-muted-foreground">
{provider.models.length === 0
? dict.admin.noModelsConfigured
: formatMessage(
provider.models.length === 1
? dict.admin.modelCount
: dict.admin.modelCountPlural,
{ count: provider.models.length },
)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Star
className={cn(
"h-3.5 w-3.5",
provider.isDefault &&
"fill-amber-400 text-amber-400",
)}
aria-hidden="true"
/>
{dict.admin.default}
<Switch
checked={!!provider.isDefault}
disabled={disabled}
aria-label={dict.admin.setAsDefault}
onCheckedChange={(checked) =>
onUpdate({ isDefault: checked })
}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
{dict.admin.delete}
</Button>
</div>
{/* Credentials (shared with the user ModelConfigDialog) */}
<ProviderCredentialsFields
provider={provider.provider}
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
disabled={disabled}
onChange={(field, value) => onUpdate({ [field]: value })}
renderSecret={({ field, id }) => (
// Bare id keeps the shared component's <Label htmlFor={id}>
// associated; only one ProviderDetail is mounted at a time.
<SecretInput
id={id}
keepOnEmpty
value={provider[field]}
disabled={disabled}
onChange={(v) => onUpdate({ [field]: v })}
/>
)}
/>
{/* Models */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{dict.admin.models}
</Label>
<div className="flex items-center gap-1.5">
<Input
value={modelInput}
disabled={disabled}
placeholder={dict.admin.modelIdPlaceholder}
spellCheck={false}
className="h-8 w-48 font-mono text-xs"
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addModel(modelInput)
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={disabled || !modelInput.trim()}
aria-label={dict.admin.addModel}
onClick={() => addModel(modelInput)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
{suggestions.length > 0 && (
<Select
value=""
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
<SelectTrigger className="h-8 w-28 text-xs">
{dict.admin.suggested}
</SelectTrigger>
<SelectContent className="max-h-72">
{suggestions.map((m) => (
<SelectItem
key={m}
value={m}
className="font-mono text-xs"
>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border">
{provider.models.length === 0 ? (
<p className="p-5 text-center text-sm text-muted-foreground">
{dict.admin.addProviderToOfferModels}
</p>
) : (
<ul className="divide-y">
{provider.models.map((modelId, index) => {
const result = testResults[modelId]
return (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{provider.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
{result && (
<span
className={cn(
"flex items-center gap-1 text-xs",
result.ok
? "text-green-600 dark:text-green-400"
: "text-destructive",
)}
>
{result.ok ? (
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<AlertCircle
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="max-w-48 truncate">
{result.message}
</span>
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
disabled || testing !== null
}
onClick={() =>
void testModel(modelId)
}
>
{testing === modelId ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Zap
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="ml-1">
{dict.admin.test}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={disabled}
aria-label={formatMessage(
dict.admin.removeModel,
{ model: modelId },
)}
onClick={() =>
onUpdate({
models: provider.models.filter(
(m) => m !== modelId,
),
})
}
>
<X
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage(dict.admin.deleteProviderTitle, {
name: provider.name || info.label,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.admin.deleteProviderDesc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.admin.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
setDeleteOpen(false)
onDelete()
}}
>
{dict.admin.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export function ModelsSection({
providers,
envProviders,
envHasDefaultModel,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
envHasDefaultModel: boolean
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
}) {
const dict = useDictionary()
const [selectedId, setSelectedId] = useState<string | null>(
providers[0]?.id ?? null,
)
const selected = providers.find((p) => p.id === selectedId)
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
id: generateId(),
provider,
models: [],
// Only the very first provider becomes the default, and only when
// the env config has no default that it would replace on save
isDefault:
providers.length === 0 &&
!envProviders.some((p) => p.isDefault) &&
!envHasDefaultModel,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
}
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
onChange(
providers.map((p) => {
if (p.id !== id) {
// Only one default at a time
return patch.isDefault ? { ...p, isDefault: false } : p
}
return { ...p, ...patch }
}),
)
}
const deleteProvider = (id: string) => {
const next = providers.filter((p) => p.id !== id)
onChange(next)
setSelectedId(next[0]?.id ?? null)
}
return (
<div className="flex min-h-72 flex-col sm:flex-row">
{/* Provider list */}
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
<div className="flex-1 space-y-1 p-2">
{providers.length === 0 && envProviders.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{dict.admin.addProviderHint}
</p>
)}
{envProviders.map((p) => (
<button
key={`env:${p.name}`}
type="button"
onClick={() => setSelectedId(`env:${p.name}`)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === `env:${p.name}` &&
"bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{dict.admin.sourceEnv}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === p.id && "bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
</div>
<div className="border-t p-2">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
<SelectTrigger className="w-full">
<Plus
className="mr-1 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
envProviders.some(
(e) => e.provider === p,
)
return (
<SelectItem
key={p}
value={p}
disabled={envBlocked}
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
dict.admin
.managedViaEnv
}
</span>
)}
</div>
</SelectItem>
)
},
)}
</SelectContent>
</Select>
</div>
</div>
{/* Detail */}
<div className="min-w-0 flex-1 p-4">
{selected ? (
<ProviderDetail
key={selected.id}
provider={selected}
disabled={disabled}
password={password}
onUpdate={(patch) => updateProvider(selected.id, patch)}
onDelete={() => deleteProvider(selected.id)}
/>
) : selectedEnv ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={selectedEnv.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">
{selectedEnv.name}
</h3>
<p className="text-xs text-muted-foreground">
{dict.admin.envReadOnly}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<ul className="divide-y">
{selectedEnv.models.map((modelId, index) => (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{selectedEnv.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{dict.admin.selectProviderHint}
</p>
)}
</div>
</div>
)
}
-645
View File
@@ -1,645 +0,0 @@
"use client"
import {
AlertTriangle,
Check,
Loader2,
LockKeyhole,
ShieldCheck,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import {
SETTING_GROUPS,
SETTINGS_BY_GROUP,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
isSecretValue,
SESSION_PASSWORD_KEY,
type SettingState,
type SettingsMap,
savedTextOf,
} from "./admin-shared"
import { ModelsSection } from "./models-section"
import { SettingField } from "./setting-field"
// ── Page ─────────────────────────────────────────────────────────────
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
// For each toggleable group, whether any of its settings has a value (from
// the settings file or the environment)
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
const result: Record<string, boolean> = {}
for (const group of SETTING_GROUPS) {
if (!group.toggleable) continue
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return result
}
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
const groupText = (id: string) =>
(
dict.admin.groups as Record<
string,
{ title: string; description: string } | undefined
>
)[id]
const navItems = NAV_GROUP_IDS.map((id) => ({
id,
title:
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
}))
const [password, setPassword] = useState("")
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
const [authError, setAuthError] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [writable, setWritable] = useState(true)
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
// Whether .env itself sets AI_MODEL (a default the panel would override)
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
// General settings state
const [settings, setSettings] = useState<SettingsMap>({})
const [pending, setPending] = useState<Record<string, string | null>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
{},
)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
ok: boolean
text: string
} | null>(null)
const [activeGroup, setActiveGroup] = useState("models")
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
const applySettingsResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// A group stays on while it still has values (e.g. from env vars
// that saving can't remove); a user's explicit "on" for a group
// with no values yet is kept across saves
setEnabledGroups((prev) => {
const next = groupsWithValues(map)
for (const id of Object.keys(next)) {
next[id] = next[id] || !!prev[id]
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
},
[],
)
const login = useCallback(
async (pw: string) => {
setAuthLoading(true)
setAuthError("")
try {
const [settingsData, providersData] = await Promise.all([
adminFetch("/api/admin/settings", pw),
adminFetch("/api/admin/providers", pw),
])
applySettingsResponse(settingsData)
applyProvidersResponse(providersData)
setAuthedPassword(pw)
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
} catch (err) {
setAuthError(
err instanceof Error ? err.message : dict.admin.loginFailed,
)
} finally {
setAuthLoading(false)
}
},
[applySettingsResponse, applyProvidersResponse, dict],
)
// Restore session on mount
useEffect(() => {
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
if (stored) void login(stored)
}, [login])
// Warn before leaving with unsaved changes
const hasDirty = dirtyCount > 0
useEffect(() => {
if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Some browsers only show the prompt when returnValue is set
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
if (!authedPassword) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
)
if (visible[0]) setActiveGroup(visible[0].target.id)
},
{ rootMargin: "-10% 0px -50% 0px" },
)
for (const id of NAV_GROUP_IDS) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [authedPassword])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback(
(key: string, value: string | null | undefined) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
})
setPending((prev) => {
const state = settings[key]
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (value === undefined || isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: value === "" ? null : value }
})
},
[settings],
)
// Toggling a group off stages deletion of its saved values so the
// feature actually turns off on save; toggling on drops those deletions.
const handleGroupToggle = useCallback(
(groupId: string, enabled: boolean) => {
setSaveMessage(null)
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
(d) => d.key,
)
setPending((prev) => {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion of saved values; drop unsaved input
if (settings[key]?.source === "default")
delete next[key]
else next[key] = null
} else if (next[key] === null) {
delete next[key]
}
}
return next
})
},
[settings],
)
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
setSaveMessage(null)
setErrors({})
try {
if (providersDirty) {
const data = await adminFetch(
"/api/admin/providers",
authedPassword,
{ method: "PUT", body: JSON.stringify({ providers }) },
)
applyProvidersResponse(data)
}
if (Object.keys(pending).length > 0) {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-password": authedPassword,
},
body: JSON.stringify({ values: pending }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
// Per-field validation errors come back as {errors: {...}}
if (data.errors) {
setErrors(data.errors)
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
throw new Error(dict.admin.invalidSettings)
}
throw new Error(
data.error || `Request failed (${res.status})`,
)
}
applySettingsResponse(data)
setPending({})
}
setSaveMessage({
ok: true,
text: dict.admin.saved,
})
setTimeout(() => setSaveMessage(null), 4000)
} catch (err) {
setSaveMessage({
ok: false,
text:
err instanceof Error ? err.message : dict.admin.saveFailed,
})
} finally {
setSaving(false)
}
}, [
authedPassword,
pending,
providers,
providersDirty,
dirtyCount,
applySettingsResponse,
applyProvidersResponse,
dict,
])
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<form
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
onSubmit={(e) => {
e.preventDefault()
void login(password)
}}
>
<div className="flex items-center gap-2">
<LockKeyhole
className="h-5 w-5 text-muted-foreground"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-sm text-muted-foreground">
{dict.admin.loginPrompt}
</p>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{dict.admin.password}
</Label>
<Input
id="admin-password"
name="admin-password"
type="password"
value={password}
autoComplete="current-password"
spellCheck={false}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<p
className={cn(
"text-sm text-destructive",
!authError && "sr-only",
)}
aria-live="polite"
>
{authError}
</p>
<Button
type="submit"
className="w-full"
disabled={authLoading}
>
{authLoading ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.signingIn}
</>
) : (
dict.admin.signIn
)}
</Button>
</form>
</div>
)
}
// ── Settings screen ──────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-xs text-muted-foreground">
{dict.admin.precedence}
</p>
</div>
</header>
{!writable && (
<div className="border-b bg-amber-500/10">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
{dict.admin.notWritable}
</div>
</div>
)}
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
<nav
aria-label={dict.admin.settingGroups}
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
>
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={
activeGroup === item.id
? "true"
: undefined
}
className={cn(
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeGroup === item.id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 pb-24">
{/* Models section */}
<section aria-labelledby="models" className="mb-10">
<h2
id="models"
className="scroll-mt-20 text-base font-semibold"
>
{dict.admin.models}
</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{dict.admin.modelsDescription}
</p>
<div className="overflow-hidden rounded-lg border bg-card">
<ModelsSection
providers={providers}
envProviders={envProviders}
envHasDefaultModel={envHasDefaultModel}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
setSaveMessage(null)
setProviders(next)
}}
/>
</div>
</section>
{/* Registry-driven groups */}
{SETTING_GROUPS.map((group) => {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
// Values from env vars can't be removed here, so the
// group can't be turned off from the panel
const envLocked = defs.some(
(d) => settings[d.key]?.source === "env",
)
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
return (
<section
key={group.id}
aria-labelledby={group.id}
className="mb-10"
>
<div className="flex items-center justify-between gap-4">
<h2
id={group.id}
className="scroll-mt-20 text-base font-semibold"
>
{title}
</h2>
{group.toggleable && (
<label
title={
envLocked
? dict.admin.sourceEnvTitle
: undefined
}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
? "border-primary/30 bg-primary/5 text-primary"
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{enabledGroups[group.id]
? dict.admin.enabled
: dict.admin.disabled}
<Switch
checked={
!!enabledGroups[group.id]
}
disabled={
!writable ||
saving ||
envLocked
}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
)}
onCheckedChange={(checked) =>
handleGroupToggle(
group.id,
checked,
)
}
/>
</label>
)}
</div>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{gt?.description ?? group.description}
</p>
<div
className={cn(
"rounded-lg border bg-card px-4",
groupOff &&
"pointer-events-none opacity-50",
)}
>
{defs.map((def) => (
<SettingField
key={def.key}
def={def}
state={settings[def.key]}
pendingValue={pending[def.key]}
error={errors[def.key]}
disabled={fieldsDisabled}
onChange={(v) =>
handleChange(def.key, v)
}
/>
))}
</div>
</section>
)
})}
</main>
</div>
{/* Always-mounted live region so save results are announced */}
<p aria-live="polite" className="sr-only">
{saveMessage?.text ?? ""}
</p>
{(dirtyCount > 0 || saveMessage) && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<p
className={cn(
"flex min-w-0 items-center gap-1.5 truncate text-sm",
saveMessage?.ok
? "text-green-600 dark:text-green-400"
: saveMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{saveMessage?.ok && (
<Check
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
{saveMessage && !saveMessage.ok
? saveMessage.text
: dirtyCount > 0
? dict.admin.unsavedChanges
: saveMessage?.text}
</p>
{dirtyCount > 0 && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
setEnabledGroups(
groupsWithValues(settings),
)
}}
>
{dict.admin.discard}
</Button>
<Button
type="button"
disabled={saving || !writable}
onClick={() => void handleSave()}
>
{saving ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.saving}
</>
) : (
dict.admin.saveChanges
)}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
-317
View File
@@ -1,317 +0,0 @@
import { Eye, EyeOff, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
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"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
isSecretValue,
type SecretValue,
type SettingState,
savedTextOf,
} from "./admin-shared"
// ── Small shared UI bits ─────────────────────────────────────────────
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
const dict = useDictionary()
if (source === "default") return null
return (
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
source === "file"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
title={
source === "file"
? dict.admin.sourceSavedTitle
: dict.admin.sourceEnvTitle
}
>
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
</span>
)
}
export function RestartBadge() {
const dict = useDictionary()
return (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
{dict.admin.restartRequired}
</span>
)
}
// Secret input: shows masked hint as placeholder, typing replaces.
// With keepOnEmpty, clearing the field reverts to the stored value
// ("keep") instead of deleting it — explicit deletion is via the X button.
export function SecretInput({
id,
value,
disabled,
keepOnEmpty,
onChange,
}: {
id: string
value: string | SecretValue | undefined
disabled?: boolean
keepOnEmpty?: boolean
onChange: (value: string | SecretValue) => void
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker to revert to on empty. Refreshed whenever the parent
// passes server state (a marker or nothing), e.g. after a save.
const [original, setOriginal] = useState(value)
if (typeof value !== "string" && value !== original) setOriginal(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
: dict.admin.notSet
const handleText = (t: string) => {
if (t === "" && keepOnEmpty && hadStored && original) {
onChange(original)
} else {
onChange(t)
}
}
return (
<div className="flex items-center gap-1">
<Input
id={id}
type={show ? "text" : "password"}
value={text}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={placeholder}
className="h-9 font-mono text-xs"
onChange={(e) => handleText(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
onClick={() => setShow((s) => !s)}
>
{show ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{keepOnEmpty && (hadStored || text) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={dict.admin.removeValue}
title={dict.admin.removeValueTitle}
onClick={() => onChange("")}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
)
}
// ── General settings field (registry-driven) ─────────────────────────
export function SettingField({
def,
state,
pendingValue,
error,
disabled,
onChange,
}: {
def: SettingDef
state: SettingState | undefined
pendingValue: string | null | undefined
error?: string
disabled: boolean
// undefined drops the pending change (back to the saved value)
onChange: (value: string | null | undefined) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretState = state && isSecretValue(state.value) ? state.value : null
// Localized label/description keyed by env var name, falling back to the
// registry's English (the registry stays canonical for the server).
const t = (
dict.admin.settings as Record<
string,
{ label?: string; description?: string } | undefined
>
)[def.key]
const label = t?.label ?? def.label
const description = t?.description ?? def.description
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
let control: React.ReactNode
switch (def.type) {
case "boolean": {
// When unset, reflect the built-in runtime default so the toggle
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
const effective =
currentValue !== "" ? currentValue : (def.default ?? "false")
// A saved boolean can be cleared back to its env/default value.
const canClear =
(isDirty && pendingValue !== null) || source === "file"
control = (
<div className="flex items-center gap-3">
<Switch
id={inputId}
checked={effective === "true"}
disabled={disabled}
onCheckedChange={(checked) =>
onChange(checked ? "true" : "false")
}
/>
{canClear && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => onChange(null)}
>
{dict.admin.resetToDefault}
</Button>
)}
</div>
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
{/* Clearing a saved secret reverts to it; the X button deletes */}
<SecretInput
id={inputId}
keepOnEmpty={source === "file"}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? undefined)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : undefined)
}
/>
</div>
)
break
case "number":
control = (
<Input
id={inputId}
type="number"
inputMode="numeric"
min={def.min}
max={def.max}
value={currentValue}
disabled={disabled}
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-xs tabular-nums"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
break
default:
control = (
<Input
id={inputId}
type="text"
value={currentValue}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-md"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="border-b border-border/60 py-4 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
<SourceChip source={source} />
{def.restartRequired && <RestartBadge />}
{isDirty && (
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
{dict.admin.modified}
</span>
)}
</div>
{description && (
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
{description}
</p>
)}
{control}
<p
id={errorId}
className={cn(
"text-xs text-destructive",
error ? "mt-1.5" : "sr-only",
)}
aria-live="polite"
>
{error ?? ""}
</p>
</div>
)
}
+6 -30
View File
@@ -1,25 +1,19 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next"
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { notFound } from "next/navigation"
import Script from "next/script"
import { AppToaster } from "@/components/workspace/app-toaster"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Locale } from "@/lib/i18n/config"
import { i18n } from "@/lib/i18n/config"
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
import { THEME_INIT_SCRIPT } from "@/stores/settings-store"
import "../globals.css"
const instrumentSans = Instrument_Sans({
variable: "--font-latin",
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
// Chinese and Japanese text falls through to system fonts, in the
// order globals.css picks for the page's language
fallback: [],
})
const jetbrainsMono = JetBrains_Mono({
@@ -47,24 +41,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang = (
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
}
return {
@@ -91,14 +80,7 @@ export async function generateMetadata({
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale:
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
images: [
{
url: "/architecture.png",
@@ -133,7 +115,6 @@ export async function generateMetadata({
en: "/en",
zh: "/zh",
ja: "/ja",
"zh-Hant": "/zh-Hant",
},
},
}
@@ -171,22 +152,17 @@ export default async function RootLayout({
return (
<html lang={validLang} suppressHydrationWarning>
<head>
{/* Apply dark mode before the first paint (no white flash) */}
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DictionaryProvider dictionary={dictionary}>
<DiagramProvider>{children}</DiagramProvider>
</DictionaryProvider>
<AppToaster />
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
+225 -6
View File
@@ -1,11 +1,230 @@
import { Suspense } from "react"
import { Workspace } from "@/components/workspace/workspace"
"use client"
import { usePathname, useRouter } from "next/navigation"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
// The workspace reads ?session= from the URL, which needs a Suspense boundary
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
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 [isDrawioReady, setIsDrawioReady] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount
useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
return // Wait for redirect
}
}
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)
}
setIsLoaded(true)
}, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady()
}
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile - reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
useEffect(() => {
const checkMobile = () => {
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
setIsDrawioReady(false)
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
if (panel.isCollapsed()) {
panel.expand()
setIsChatVisible(true)
} else {
panel.collapse()
setIsChatVisible(false)
}
}
}
// Keyboard shortcut for toggling chat panel
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault()
toggleChatPanel()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
return (
<Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
<Workspace />
</Suspense>
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
id="main-panel-group"
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
<ResizablePanel
id="drawio-panel"
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 relative">
{isLoaded && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`}
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark: darkMode,
lang: currentLang,
}}
/>
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Chat Panel */}
<ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
minSize={isMobile ? 20 : 15}
maxSize={isMobile ? 80 : 50}
collapsible={!isMobile}
collapsedSize={isMobile ? 0 : 3}
onCollapse={() => setIsChatVisible(false)}
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense
fallback={
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
Loading chat...
</div>
}
>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
/>
</Suspense>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}
-96
View File
@@ -1,96 +0,0 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import {
getEnvFallback,
isSettingsWritable,
saveSettings,
} from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function payload() {
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
// read-only in the panel; their credentials live in the environment
const envConfig = await loadEnvServerModelsConfig()
const adminProviders = loadAdminProviders()
// A panel default overrides any env default (matches the merge in
// loadRawServerModelsConfig), so env stars must reflect that
const adminHasDefault = adminProviders.some(
(p) => p.isDefault && p.models.length > 0,
)
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(adminProviders),
envProviders:
envConfig?.providers.map((p) => ({
name: p.name,
provider: p.provider,
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
// Whether .env sets a default model. getEnvFallback skips the value
// the panel overlays onto process.env, so a panel default doesn't count.
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(await payload())
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProvidersSchema.safeParse(
(body as { providers?: unknown })?.providers,
)
if (!parsed.success) {
return Response.json(
{
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
},
{ status: 400 },
)
}
const stored = loadAdminProviders()
const merged = mergeSecrets(parsed.data, stored)
const envConfig = await loadEnvServerModelsConfig()
const validationError = validateAdminProviders(merged, envConfig)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(await payload())
}
-126
View File
@@ -1,126 +0,0 @@
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
import {
SETTINGS_BY_KEY,
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
writable: isSettingsWritable(),
settings: serializeSettings(),
})
}
function validateValue(def: SettingDef, value: string): string | null {
switch (def.type) {
case "number": {
const num = Number(value)
if (!Number.isFinite(num)) return "Must be a number"
if (def.min !== undefined && num < def.min)
return `Must be at least ${def.min}`
if (def.max !== undefined && num > def.max)
return `Must be at most ${def.max}`
return null
}
case "boolean":
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: { values?: Record<string, unknown> }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
if (!body.values || typeof body.values !== "object") {
return Response.json(
{ error: "Body must contain a values object" },
{ status: 400 },
)
}
const updates: Record<string, string | null> = {}
const errors: Record<string, string> = {}
for (const [key, value] of Object.entries(body.values)) {
const def = SETTINGS_BY_KEY.get(key)
if (!def) {
errors[key] = "Unknown setting"
continue
}
if (value === null || value === "") {
updates[key] = null
continue
}
if (typeof value !== "string") {
errors[key] = "Value must be a string"
continue
}
const error = validateValue(def, value)
if (error) {
errors[key] = error
continue
}
updates[key] = value
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
saveSettings(updates)
return Response.json({
writable: true,
settings: serializeSettings(),
})
}
-84
View File
@@ -1,84 +0,0 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
loadAdminProviders,
mergeSecrets,
} from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a model with the client's CURRENT provider state (which may be
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
// masked {isSet} markers, which are resolved against settings.json — so
// testing works both before and after saving.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { provider?: unknown; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProviderSchema.safeParse(body.provider)
if (!parsed.success || !body.modelId) {
return Response.json(
{ valid: false, error: "Invalid provider or model" },
{ status: 400 },
)
}
// SECURITY: a stored secret is only resolved from an {isSet} marker if
// the endpoint it would be sent to (provider + baseUrl) still matches
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
const sameEndpoint =
stored &&
stored.provider === parsed.data.provider &&
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
const [resolved] = mergeSecrets(
[parsed.data],
sameEndpoint && stored ? [stored] : [],
)
const serverUrl = globalBaseUrl(resolved.provider)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: {
"Content-Type": "application/json",
// Checked again there, in place of an access code
"x-admin-password": req.headers.get("x-admin-password") || "",
// The EdgeOne function checks the access code and Pages
// cookies, and its URL is built from the page's origin
"x-access-code": req.headers.get("x-access-code") || "",
cookie: req.headers.get("cookie") || "",
...(req.headers.get("origin") && {
origin: req.headers.get("origin") as string,
}),
},
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
// Without a URL of its own, chat sends the entry's key to
// the server's <P>_BASE_URL: test that endpoint, not
// another one. It is the server's own, which chat uses
// without the checks for a URL a user typed.
baseUrl: resolved.baseUrl || serverUrl,
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}
+403 -425
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(),
})
+12 -96
View File
@@ -1,46 +1,12 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { readLimitedBody } from "@/lib/read-limited-body"
import { isPrivateUrl } from "@/lib/ssrf-protection"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const { url } = await req.json()
@@ -61,61 +27,28 @@ export async function POST(req: Request) {
)
}
// SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (await isPrivateUrl(url)) {
// SSRF protection
if (!allowPrivateUrls && isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Fetch the page ourselves so we control redirect handling. The
// article-extractor library follows redirects internally and ignores a
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let html: string
let article
try {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
redirect: "error",
article = await extract(url, undefined, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal,
})
const contentType = response.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
},
{ status: 422 },
)
}
if (!response.ok) {
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
}
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
if (!buffer) {
return NextResponse.json(
{
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
},
{ status: 413 },
)
}
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
@@ -123,26 +56,9 @@ export async function POST(req: Request) {
{ status: 504 },
)
}
// Redirects are rejected with a TypeError ("failed to fetch" /
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
throw err
} finally {
clearTimeout(timeoutId)
// Ends a download left unread (too large, PDF, error status);
// a body already read is not affected
controller.abort()
}
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
}
if (!article || !article.content) {
-79
View File
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { classifyLLMError } from "@/lib/llm-errors"
import {
canListModels,
listProviderModels,
ModelListError,
} from "@/lib/provider-models"
import {
allowPrivateUrls,
isPrivateUrl,
RedirectRefusedError,
redirectGuardedFetch,
} from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
// Public lists need no key
const NO_KEY_NEEDED = new Set<ProviderName>([
"ollama",
"openrouter",
"aihubmix",
])
/**
* The models a provider offers, for the "Fetch models" button in model
* settings. Answers { models: null } for providers that cannot list them,
* so the dialog keeps its suggested models.
*/
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Sends requests to a URL the client chose, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
const { provider, apiKey, baseUrl } = (await req.json()) as {
provider: ProviderName
apiKey?: string
baseUrl?: string
}
if (!canListModels(provider)) {
return NextResponse.json({ models: null })
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
}
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
)
}
try {
const models = await listProviderModels(
provider,
{ apiKey, baseUrl },
(baseUrl && redirectGuardedFetch()) || fetch,
)
return NextResponse.json({ models })
} catch (error) {
console.warn("[provider-models] Listing failed:", error)
// Only our own explanations go back: the URL may be an internal
// address, whose answer or host names must not reach the caller.
// The Gateway SDK wraps them, keeping ours as the cause.
const isOwn = (e: unknown): e is Error =>
e instanceof ModelListError || e instanceof RedirectRefusedError
const cause = (error as { cause?: unknown })?.cause
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
const { code } = classifyLLMError(own ?? error)
return NextResponse.json({
code,
error: own?.message ?? "The model list request failed.",
})
}
}
+19 -67
View File
@@ -3,15 +3,8 @@
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { Output, streamText } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
@@ -20,9 +13,6 @@ import {
export const maxDuration = 30
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
@@ -35,20 +25,25 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/** A fixed result in the text format useObject reads */
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
return new Response(JSON.stringify(result), {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
@@ -77,42 +72,6 @@ export async function POST(req: Request): Promise<Response> {
)
}
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
return Response.json(
{ error: "Image data too large" },
{ status: 413 },
)
}
// It runs the server's vision model: with the quota on, the daily
// and per-minute token limits apply, and its tokens are counted. Not
// the request limit, which is for chats: the day's last chat still
// gets its check, and a check does not count as a chat.
const userId = getUserIdFromRequest(req)
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(
userId,
{
requests: 0,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
},
0,
)
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// Get the validation model
let model
try {
@@ -134,9 +93,9 @@ export async function POST(req: Request): Promise<Response> {
) || 10000
// Stream the VLM response for useObject consumption
const result = streamText({
const result = streamObject({
model,
output: Output.object({ schema: ValidationResultSchema }),
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
@@ -155,17 +114,10 @@ export async function POST(req: Request): Promise<Response> {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output, totalUsage }) => {
if (countsQuota && totalUsage) {
recordTokenUsage(
userId,
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0),
)
}
if (sessionId && output) {
onFinish: ({ object }) => {
if (sessionId && object) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
)
}
},
+273 -155
View File
@@ -1,28 +1,20 @@
import { streamText, tool } from "ai"
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
edgeOneEndpoint,
getAIModel,
globalBaseUrl,
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
} from "@/lib/dynamo-quota-manager"
import { classifyLLMError } from "@/lib/llm-errors"
import { createOllama } from "ollama-ai-provider-v2"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
export const runtime = "nodejs"
interface ValidateRequest {
provider: ProviderName
provider: string
apiKey: string
baseUrl?: string
modelId: string
@@ -30,42 +22,21 @@ interface ValidateRequest {
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
serverBaseUrl?: boolean
}
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError
try {
const body: ValidateRequest = await req.json()
const {
provider,
apiKey,
baseUrl,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
@@ -76,26 +47,9 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
// EdgeOne is this site's own function, as in the chat; the admin
// panel's Test sends no URL, and a relative one cannot be fetched
const baseUrl =
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
// The admin panel's Test of an entry without a URL sends the
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
// user chose, so no private-address or redirect rules
const serverUrl =
body.serverBaseUrl === true &&
!!baseUrl &&
baseUrl === globalBaseUrl(provider) &&
!checkAdminAuth(req)
// SECURITY: Block SSRF attacks via custom baseUrl
if (
baseUrl &&
!serverUrl &&
!allowPrivateUrls() &&
(await isPrivateUrl(baseUrl))
) {
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -129,117 +83,281 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// On the deployment's own endpoints a Test runs a model as a chat
// does, so with the quota on it counts as a chat request (an
// admin's Test of the server's URL does not)
const userId = getUserIdFromRequest(req)
if (
isQuotaEnabled() &&
!serverUrl &&
userId !== "anonymous" &&
(await usesServerEndpoint(
provider,
normalizeBaseUrl(body.baseUrl ?? ""),
apiKey,
))
) {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return NextResponse.json(
{ valid: false, error: quotaCheck.error },
{ status: 429 },
)
}
}
let model: any
// The same model the chat would use. A client base URL makes it
// refuse redirects to internal hosts.
const { model } = getAIModel({
provider,
modelId,
apiKey,
baseUrl,
trustedBaseUrl: serverUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Temporary AWS credentials need it, as in the chat
awsSessionToken,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
switch (provider) {
case "openai": {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openai.chat(modelId)
break
}
if (part.type === "finish") finishReason = part.finishReason
case "anthropic": {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
})
model = anthropic(modelId)
break
}
case "google": {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = google(modelId)
break
}
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = azure.chat(modelId)
break
}
case "bedrock": {
const bedrock = createAmazonBedrock({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
region: awsRegion,
})
model = bedrock(modelId)
break
}
case "openrouter": {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openrouter(modelId)
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = ds(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
const ollama = createOllama({
baseURL: baseUrl || "http://localhost:11434",
})
model = ollama(modelId)
break
}
case "gateway": {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = gw(modelId)
break
}
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
headers: {
cookie: cookieHeader,
},
})
model = edgeone.chat(modelId)
break
}
case "sglang": {
// SGLang is OpenAI-compatible
const sglang = createOpenAI({
apiKey: apiKey || "not-needed",
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
})
model = sglang.chat(modelId)
break
}
case "doubao": {
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubaoBaseUrl =
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
const lowerModelId = modelId.toLowerCase()
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break
}
case "modelscope": {
const baseURL =
baseUrl || "https://api-inference.modelscope.cn/v1"
const startTime = Date.now()
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
`${baseURL}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: "user", content: "Say 'OK'" },
],
max_tokens: 20,
stream: true,
enable_thinking: false,
}),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
)
}
const contentType =
response.headers.get("content-type") || ""
const isValidStreamingResponse =
response.status === 200 &&
(contentType.includes("text/event-stream") ||
contentType.includes("application/json"))
if (!isValidStreamingResponse) {
throw new Error(
`Unexpected response format: ${contentType}`,
)
}
const responseTime = Date.now() - startTime
if (response.body) {
response.body.cancel().catch(() => {
/* Ignore cancellation errors */
})
}
return NextResponse.json({
valid: true,
responseTime,
note: "ModelScope model validated (using streaming API)",
})
} catch (error) {
console.error(
"[validate-model] ModelScope validation failed:",
error,
)
throw error
}
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
)
}
// Make a minimal test request
const startTime = Date.now()
await generateText({
model,
prompt: "Say 'OK'",
maxOutputTokens: 20,
})
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
})
} catch (error) {
console.error("[validate-model] Error:", error)
const { code, message } = classifyLLMError(error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
return NextResponse.json(
{ valid: false, code, error: message },
{ valid: false, error: errorMessage },
{ status: 200 }, // Return 200 so client can read error message
)
}
+24 -4
View File
@@ -1,9 +1,29 @@
import { checkAccessCode } from "@/lib/access-code"
export async function POST(req: Request) {
if (checkAccessCode(req)) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
return Response.json(
{ valid: false, message: "Invalid or missing access code" },
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ status: 401 },
)
}
+245 -263
View File
@@ -8,7 +8,7 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-latin), var(--font-cjk), system-ui, sans-serif;
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@@ -43,13 +43,157 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* App-specific tokens */
--color-canvas: var(--canvas);
--color-faint: var(--faint);
--color-marker: var(--marker);
--color-marker-soft: var(--marker-soft);
--color-marker-ink: var(--marker-ink);
:root {
--radius: 0.75rem;
/* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */
--muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Muted rose destructive */
--destructive: oklch(0.45 0.12 10);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260);
/* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.5 0.15 200);
/* Sidebar */
--sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265);
}
.dark {
--background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.55 0.12 10);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);
--ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.7 0.16 265);
}
/* ============================================
REFINED MINIMAL DESIGN SYSTEM
============================================ */
:root {
/* Surface layers for depth */
--surface-0: oklch(1 0 0);
--surface-1: oklch(0.985 0.002 240);
--surface-2: oklch(0.97 0.004 240);
--surface-elevated: oklch(1 0 0);
/* Subtle borders */
--border-subtle: oklch(0.94 0.008 260);
--border-default: oklch(0.91 0.012 260);
/* Interactive states */
--interactive-hover: oklch(0.96 0.015 260);
--interactive-active: oklch(0.93 0.02 265);
/* Success state */
--success: oklch(0.65 0.18 145);
--success-muted: oklch(0.95 0.03 145);
/* Animation timing */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dark {
--surface-0: oklch(0.15 0.015 260);
--surface-1: oklch(0.18 0.015 260);
--surface-2: oklch(0.22 0.015 260);
--surface-elevated: oklch(0.25 0.015 260);
--border-subtle: oklch(0.25 0.012 260);
--border-default: oklch(0.3 0.015 260);
--interactive-hover: oklch(0.25 0.02 265);
--interactive-active: oklch(0.3 0.025 270);
--success: oklch(0.7 0.16 145);
--success-muted: oklch(0.25 0.04 145);
}
/* Expose surface colors to Tailwind */
@theme inline {
--color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
@@ -62,185 +206,12 @@
--color-success-muted: var(--success-muted);
}
/*
* Light: a quiet light table. Ink for primary actions, a highlighter
* yellow reserved for what the AI just changed.
*/
:root {
--radius: 0.75rem;
--canvas: #f4f5f7;
--background: #f4f5f7;
--foreground: #14181d;
--card: #ffffff;
--card-foreground: #14181d;
--popover: #ffffff;
--popover-foreground: #14181d;
--primary: #14181d;
--primary-foreground: #ffffff;
--secondary: #f1f3f5;
--secondary-foreground: #14181d;
--muted: #f1f3f5;
--muted-foreground: #5d6672;
--faint: #687180;
--accent: #eef0f3;
--accent-foreground: #14181d;
--destructive: #c9372c;
--border: #e4e7eb;
--input: #dfe3e8;
--ring: #14181d;
--marker: #ffd84d;
--marker-soft: #fff4bf;
--marker-ink: #6b5200;
--chart-1: #3b6fd8;
--chart-2: #1f9e86;
--chart-3: #e8892c;
--chart-4: #c2417a;
--chart-5: #2a8fb8;
--sidebar: #ffffff;
--sidebar-foreground: #14181d;
--sidebar-primary: #14181d;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f3f5;
--sidebar-accent-foreground: #14181d;
--sidebar-border: #e4e7eb;
--sidebar-ring: #14181d;
--surface-0: #ffffff;
--surface-1: #f7f8fa;
--surface-2: #f1f3f5;
--surface-elevated: #ffffff;
--border-subtle: #eceef1;
--border-default: #e4e7eb;
--interactive-hover: #f1f3f5;
--interactive-active: #e7eaee;
--success: #1f9e5a;
--success-muted: #e3f5ea;
--shadow-float:
0 1px 2px rgb(20 24 29 / 0.05), 0 10px 30px -8px rgb(20 24 29 / 0.14);
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/*
* Han characters are drawn differently in Simplified Chinese, Traditional
* Chinese and Japanese: each language lists its own system fonts first.
*/
:root {
--font-cjk:
"PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC",
"Microsoft YaHei", "Hiragino Sans", "Meiryo";
}
:root:lang(ja) {
--font-cjk:
"Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP",
"Yu Gothic UI", "Meiryo", "PingFang SC";
}
:root:lang(zh-Hant) {
--font-cjk:
"PingFang TC", "Noto Sans CJK TC", "Microsoft JhengHei",
"Hiragino Sans", "PingFang SC";
}
/*
* Dark: a blueprint-blue table. Surfaces get lighter the higher they float.
* The diagram stays on a white sheet so it looks the way it will export;
* primary actions are paper-white, and amber only marks AI changes.
*/
.dark {
--canvas: #0f2440;
--background: #0c1a29;
--foreground: #e9f0f6;
--card: #16304f;
--card-foreground: #e9f0f6;
--popover: #1b3a5d;
--popover-foreground: #e9f0f6;
--primary: #e9f0f6;
--primary-foreground: #0c1a29;
--secondary: #1d3b5e;
--secondary-foreground: #e9f0f6;
--muted: #1d3b5e;
--muted-foreground: #a8bbce;
--faint: #8aa0b6;
--accent: #21426a;
--accent-foreground: #e9f0f6;
--destructive: #f07167;
--border: rgb(170 200 230 / 0.16);
--input: rgb(170 200 230 / 0.24);
--ring: #e9f0f6;
--marker: #f5b13d;
--marker-soft: rgb(245 177 61 / 0.16);
--marker-ink: #f7c46b;
--chart-1: #7aa2ff;
--chart-2: #4fd1b4;
--chart-3: #f5b13d;
--chart-4: #f08bb4;
--chart-5: #5cc3ea;
--sidebar: #16304f;
--sidebar-foreground: #e9f0f6;
--sidebar-primary: #e9f0f6;
--sidebar-primary-foreground: #0c1a29;
--sidebar-accent: #1d3b5e;
--sidebar-accent-foreground: #e9f0f6;
--sidebar-border: rgb(170 200 230 / 0.16);
--sidebar-ring: #e9f0f6;
--surface-0: #16304f;
--surface-1: #132b48;
--surface-2: #1d3b5e;
--surface-elevated: #1b3a5d;
--border-subtle: rgb(170 200 230 / 0.12);
--border-default: rgb(170 200 230 / 0.2);
--interactive-hover: #21426a;
--interactive-active: #264a75;
--success: #4fd18b;
--success-muted: rgb(79 209 139 / 0.14);
--shadow-float:
0 0 0 1px rgb(170 200 230 / 0.1), 0 14px 34px -12px rgb(0 0 0 / 0.55);
--shadow-pop:
0 0 0 1px rgb(170 200 230 / 0.14), 0 22px 56px -14px rgb(0 0 0 / 0.65);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground font-sans;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--marker-soft);
color: inherit;
}
}
@@ -250,61 +221,49 @@
width: 100% !important;
}
/* Custom scrollbar */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
transparent;
scrollbar-color: oklch(0.85 0.01 260) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
background-color: oklch(0.85 0.01 260);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in oklab,
var(--foreground) 30%,
transparent
);
background-color: oklch(0.75 0.01 260);
}
/* Diagram pictures always render as on paper, also in dark mode
(draw.io SVGs use light-dark() colors) */
.sheet-light {
color-scheme: light;
/* Dark mode scrollbar */
.dark .scrollbar-thin {
scrollbar-color: oklch(0.35 0.015 260) transparent;
}
.shadow-float {
box-shadow: var(--shadow-float);
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.35 0.015 260);
}
.shadow-pop {
box-shadow: var(--shadow-pop);
}
/* Older names kept for components that still use them */
.shadow-soft {
box-shadow: var(--shadow-float);
}
.shadow-soft-lg {
box-shadow: var(--shadow-pop);
}
.shadow-dialog {
box-shadow: var(--shadow-pop);
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.45 0.015 260);
}
}
/* Smooth page transitions */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -323,44 +282,83 @@
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
/* Message bubble animations */
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* The first prompt moves from the middle of the canvas into the panel */
@keyframes panelIn {
from {
opacity: 0;
transform: translateX(12px) scale(0.985);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes heroOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
}
.animate-message-in {
animation: messageIn 0.25s ease-out forwards;
}
/* Subtle floating shadow for cards */
.shadow-soft {
box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04);
}
.shadow-soft-lg {
box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06);
}
/* Gradient text utility */
.text-gradient-primary {
background: linear-gradient(
135deg,
oklch(0.55 0.18 265),
oklch(0.6 0.2 290)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ============================================
REFINED DIALOG STYLES
============================================ */
/* Refined dialog shadow - multi-layer soft shadow */
.shadow-dialog {
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.03),
0 2px 4px oklch(0 0 0 / 0.02),
0 12px 24px oklch(0 0 0 / 0.06),
0 24px 48px oklch(0 0 0 / 0.04);
}
.dark .shadow-dialog {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.05),
0 2px 4px oklch(0 0 0 / 0.2),
0 12px 24px oklch(0 0 0 / 0.3),
0 24px 48px oklch(0 0 0 / 0.2);
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.97);
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
@@ -375,10 +373,19 @@
}
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.97);
transform: translate(-50%, -48%) scale(0.96);
}
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
/* Check pop animation for validation success */
@keyframes check-pop {
0% {
transform: scale(0.8);
@@ -393,37 +400,12 @@
}
}
.animate-fade-in {
animation: fadeIn 0.25s var(--ease-out) forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s var(--ease-out) forwards;
}
.animate-message-in {
animation: messageIn 0.22s var(--ease-out) forwards;
}
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
.animate-hero-out {
animation: heroOut 0.2s var(--ease-out) forwards;
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-slide-in-right,
.animate-message-in,
.animate-panel-in,
.animate-hero-out,
.animate-dialog-in,
.animate-dialog-out,
.animate-check-pop {
+2 -8
View File
@@ -1,18 +1,12 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!public",
"!packages/mcp-server/src/preview",
"!lib/model-catalog.json"
]
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
+15 -58
View File
@@ -1,6 +1,5 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
className,
...props
}: ModelSelectorListProps) => {
const listRef = useRef<ElementRef<typeof CommandList>>(null)
const [showShadow, setShowShadow] = useState(false)
useEffect(() => {
const listElement = listRef.current
if (!listElement) return
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listElement
// Show shadow if there is more content below
// Using a small threshold to handle fractional pixel rendering
setShowShadow(
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
)
}
// Initial check
checkScroll()
// Event listeners
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Observe content changes (e.g. async loading of items)
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
-202
View File
@@ -1,202 +0,0 @@
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"
import { DrawioFrame } from "@/components/canvas/drawio-frame"
import { PageTabs } from "@/components/canvas/page-tabs"
import { SelectionAsk } from "@/components/canvas/selection-ask"
import { useChatEngine } from "@/components/chat/chat-engine"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getDrawioCss, getDrawioSrc } from "@/lib/drawio/drawio-config"
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
/** File types the "Open .drawio file" picker accepts */
export const DIAGRAM_FILE_ACCEPT =
".drawio,.xml,.svg,application/xml,image/svg+xml"
/**
* The draw.io canvas with the app's own toolbar, page tabs and the
* "ask AI" button laid over it. With an external (cross-origin) draw.io the
* overlays are hidden and draw.io's own toolbar is used.
*/
export function CanvasStage({
className,
style,
compact = false,
hideOverlays = false,
}: {
className?: string
style?: React.CSSProperties
compact?: boolean
/** Hide toolbar and buttons (e.g. while the start screen covers the canvas) */
hideOverlays?: boolean
}) {
const dict = useDictionary()
const params = useParams<{ lang: string }>()
const lang = (
i18n.locales.includes(params.lang as Locale)
? params.lang
: i18n.defaultLocale
) as Locale
const {
drawioRef,
handleDiagramAutoSave,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
isDrawioReady,
} = useDiagram()
const engine = useChatEngine()
const isDark = useSettingsStore((s) => s.isDark)
const hasEditor = useCanvasStore((s) => s.hasEditor)
// The model sees the first page only, so asking about shapes elsewhere
// would change the wrong page
const onFirstPage = useCanvasStore(
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
)
// Canvas width before the chat panel slides (React passes no
// transitionstart, so this is the width after the previous slide)
const settledWidthRef = useRef(0)
const [src, setSrc] = useState<string | null>(null)
// null until draw.io tells us whether we can drive it directly
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
// Bumped to reload draw.io when its CSS can only change with a reload
const [reloadKey, setReloadKey] = useState(0)
const stageRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
setSrc(getDrawioSrc({ lang, isElectron }))
}, [lang])
// A new iframe starts over: draw.io reports load and access again
useEffect(() => {
resetDrawioReady()
setEditorAccess(null)
}, [src, reloadKey])
// Cross-origin draw.io only takes CSS when it starts, so reload it when
// the theme changes. Same-origin updates the CSS in place.
const lastDarkRef = useRef(isDark)
useEffect(() => {
if (lastDarkRef.current === isDark) return
lastDarkRef.current = isDark
if (editorAccess === false) setReloadKey((key) => key + 1)
}, [isDark, editorAccess])
useEffect(() => {
const stage = stageRef.current
if (!stage) return
settledWidthRef.current = stage.clientWidth
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
refitIfRecent()
})
observer.observe(stage)
return () => observer.disconnect()
}, [])
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
// The full toolbar is about 590 px wide; a narrower canvas (small window
// with the chat panel open) gets the short one
const compactToolbar = compact || (size.width > 0 && size.width < 640)
return (
<div
ref={stageRef}
className={cn("absolute overflow-hidden bg-canvas", className)}
style={style}
data-testid="canvas-stage"
// The chat panel slides in or out (the canvas's right edge moves):
// once the canvas has its final size, fit a diagram just drawn,
// or keep the view's middle
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (event.propertyName !== "right") return
const stage = event.currentTarget
// Let draw.io take the new size first
setTimeout(() => {
const before = settledWidthRef.current
settledWidthRef.current = stage.clientWidth
if (refitIfRecent(4000)) return
keepCenter(before, stage.clientWidth)
}, 60)
}}
>
{src && (
<DrawioFrame
key={`${src}#${reloadKey}`}
ref={drawioRef}
src={src}
configCss={getDrawioCss({
dark: isDark,
customChrome: false,
})}
editorCss={getDrawioCss({
dark: isDark,
customChrome: true,
})}
onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onEditorAccess={setEditorAccess}
/>
)}
{/* Covers the iframe until draw.io has loaded and been styled */}
{(!isDrawioReady || editorAccess === null) && (
<div
className="absolute inset-0 flex items-center justify-center bg-canvas"
data-testid="canvas-loading"
>
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
{dict.workspace.canvasLoading}
</div>
</div>
)}
{showOverlays && (
<>
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center px-3">
<CanvasToolbar
compact={compactToolbar}
onOpenFile={() => fileInputRef.current?.click()}
/>
</div>
{/* Full width, so the tabs' max width is the canvas's */}
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex">
<PageTabs />
</div>
<SelectionAsk
width={size.width}
height={size.height}
hidden={engine.isBusy || !onFirstPage}
/>
</>
)}
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) engine.openDiagramFile(file)
event.target.value = ""
}}
/>
</div>
)
}
-392
View File
@@ -1,392 +0,0 @@
import {
Check,
Download,
Ellipsis,
Maximize2,
Minus,
MousePointer2,
Paintbrush,
PenLine,
Plus,
Redo2,
Shapes,
Spline,
Type,
Undo2,
} from "lucide-react"
import { useMemo, useState } from "react"
import { useChatEngine } from "@/components/chat/chat-engine"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { IconButton, ToolbarDivider } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import {
fitDiagram,
hasAction,
runAction,
stopFreehand,
toggleShapesPanel,
viewToggles,
} from "@/lib/drawio/editor-bridge"
import { isMac, modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const mod = modKey
interface MenuItem {
action: string
label: string
shortcut?: string
}
function MenuButton({
children,
onClick,
shortcut,
checked,
disabled,
}: {
children: React.ReactNode
onClick: () => void
shortcut?: string
/** On/off items: a check mark when on, and room for it when off */
checked?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
{...(checked !== undefined && {
role: "menuitemcheckbox",
"aria-checked": checked,
})}
className="flex w-full items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-left text-[13px] text-foreground hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
>
<span className="flex items-center gap-2">
{checked !== undefined && (
<Check
className={cn(
"size-3.5 shrink-0",
!checked && "invisible",
)}
/>
)}
{children}
</span>
{shortcut && (
<span className="text-xs text-muted-foreground">
{shortcut}
</span>
)}
</button>
)
}
/**
* The canvas toolbar. Every button calls an existing draw.io action; a
* button whose action is missing in this draw.io build is not shown.
*/
export function CanvasToolbar({
compact = false,
onOpenFile,
}: {
compact?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.workspace
const canUndo = useCanvasStore((s) => s.canUndo)
const canRedo = useCanvasStore((s) => s.canRedo)
const zoom = useCanvasStore((s) => s.zoom)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const isFormatOpen = useCanvasStore((s) => s.isFormatOpen)
const isShapesOpen = useCanvasStore((s) => s.isShapesOpen)
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const engine = useChatEngine()
const [moreOpen, setMoreOpen] = useState(false)
const [zoomOpen, setZoomOpen] = useState(false)
// Checked once per mount; hasAction warns about missing actions
const available = useMemo(() => {
const names = [
"undo",
"redo",
"toggleShapes",
"insertText",
"insertEdge",
"insertFreehand",
"zoomIn",
"zoomOut",
"fitWindow",
"resetView",
"format",
"findReplace",
"editDiagram",
"layers",
"outline",
"grid",
"pageView",
"insertImage",
"insertLink",
"selectAll",
"print",
]
return new Set(names.filter((name) => hasAction(name)))
}, [])
// Read when the menu opens (draw.io has no event for these)
const toggles = moreOpen ? viewToggles() : { grid: false, pageView: false }
const checkedOf = (action: string) =>
action === "grid"
? toggles.grid
: action === "pageView"
? toggles.pageView
: undefined
const moreItems: MenuItem[] = [
{ action: "findReplace", label: t.findReplace, shortcut: `${mod}F` },
{ action: "layers", label: t.layers, shortcut: `${mod}⇧L` },
{ action: "outline", label: t.outline, shortcut: `${mod}⇧O` },
{ action: "grid", label: t.toggleGrid, shortcut: `${mod}⇧G` },
{ action: "pageView", label: t.togglePageView },
{ action: "insertImage", label: t.insertImage },
{ action: "insertLink", label: t.insertLink },
{ action: "selectAll", label: t.selectAll, shortcut: `${mod}A` },
{ action: "editDiagram", label: t.editDiagram },
{ action: "print", label: t.print, shortcut: `${mod}P` },
].filter((item) => available.has(item.action))
const tool = (action: string) => () => {
if (isFreehand && action !== "insertFreehand") stopFreehand()
runAction(action)
}
return (
<div
role="toolbar"
aria-label={t.toolbar}
className={cn(
"pointer-events-auto flex items-center gap-0.5 rounded-xl bg-card p-1 shadow-float",
compact && "max-w-full overflow-x-auto",
)}
>
{available.has("undo") && (
<IconButton
label={t.undo}
shortcut={`${mod}Z`}
onClick={tool("undo")}
disabled={!canUndo}
data-testid="toolbar-undo"
>
<Undo2 />
</IconButton>
)}
{available.has("redo") && (
<IconButton
label={t.redo}
shortcut={isMac ? "⌘⇧Z" : "Ctrl+Y"}
onClick={tool("redo")}
disabled={!canRedo}
>
<Redo2 />
</IconButton>
)}
<ToolbarDivider />
{!compact && (
<IconButton
label={t.select}
active={!isFreehand}
onClick={() => stopFreehand()}
>
<MousePointer2 />
</IconButton>
)}
{available.has("toggleShapes") && (
<IconButton
label={t.shapes}
shortcut={`${mod}⇧K`}
active={isShapesOpen}
onClick={() => {
if (isFreehand) stopFreehand()
toggleShapesPanel()
}}
data-testid="toolbar-shapes"
>
<Shapes />
</IconButton>
)}
{!compact && available.has("insertText") && (
<IconButton label={t.text} onClick={tool("insertText")}>
<Type />
</IconButton>
)}
{!compact && available.has("insertEdge") && (
<IconButton label={t.connector} onClick={tool("insertEdge")}>
<Spline />
</IconButton>
)}
{!compact && available.has("insertFreehand") && (
<IconButton
label={t.freehand}
shortcut="X"
active={isFreehand}
onClick={tool("insertFreehand")}
>
<PenLine />
</IconButton>
)}
<ToolbarDivider />
{!compact && available.has("zoomOut") && (
<IconButton
label={t.zoomOut}
shortcut={`${mod}−`}
onClick={tool("zoomOut")}
>
<Minus />
</IconButton>
)}
<Popover open={zoomOpen} onOpenChange={setZoomOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t.zoomMenu}
className="h-8 min-w-12 rounded-lg px-1.5 text-xs tabular-nums text-muted-foreground hover:bg-accent hover:text-foreground"
>
{zoom}%
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="center">
{available.has("fitWindow") && (
<MenuButton
shortcut={`${mod}⇧H`}
onClick={() => {
runAction("fitWindow")
setZoomOpen(false)
}}
>
{t.fitWindow}
</MenuButton>
)}
{available.has("resetView") && (
<MenuButton
shortcut={`${mod}H`}
onClick={() => {
runAction("resetView")
setZoomOpen(false)
}}
>
{t.zoomReset}
</MenuButton>
)}
{compact && available.has("zoomIn") && (
<MenuButton onClick={() => runAction("zoomIn")}>
{t.zoomIn}
</MenuButton>
)}
{compact && available.has("zoomOut") && (
<MenuButton onClick={() => runAction("zoomOut")}>
{t.zoomOut}
</MenuButton>
)}
</PopoverContent>
</Popover>
{!compact && available.has("zoomIn") && (
<IconButton
label={t.zoomIn}
shortcut={`${mod}+`}
onClick={tool("zoomIn")}
>
<Plus />
</IconButton>
)}
{!compact && (
<IconButton
label={t.fitWindow}
onClick={() => fitDiagram()}
data-testid="toolbar-fit"
>
<Maximize2 />
</IconButton>
)}
<ToolbarDivider />
{available.has("format") && (
<IconButton
label={t.style}
shortcut={`${mod}⇧P`}
active={isFormatOpen}
onClick={tool("format")}
data-testid="toolbar-style"
>
<Paintbrush />
</IconButton>
)}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<IconButton label={t.more} data-testid="toolbar-more">
<Ellipsis />
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-60 p-1" align="end">
<MenuButton
disabled={engine.isBusy}
onClick={() => {
setMoreOpen(false)
onOpenFile()
}}
>
{t.openFile}
</MenuButton>
{compact && available.has("insertText") && (
<MenuButton
onClick={() => {
setMoreOpen(false)
runAction("insertText")
}}
>
{t.text}
</MenuButton>
)}
{compact && available.has("insertFreehand") && (
<MenuButton
onClick={() => {
setMoreOpen(false)
runAction("insertFreehand")
}}
>
{t.freehand}
</MenuButton>
)}
<div className="my-1 h-px bg-border" />
{moreItems.map((item) => (
<MenuButton
key={item.action}
checked={checkedOf(item.action)}
shortcut={compact ? undefined : item.shortcut}
onClick={() => {
setMoreOpen(false)
runAction(item.action)
}}
>
{item.label}
</MenuButton>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={() => setSaveDialogOpen(true)}
className="ml-1 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-opacity hover:opacity-90"
data-testid="toolbar-export"
>
<Download className="size-4" />
{!compact && t.export}
</button>
</div>
)
}
-233
View File
@@ -1,233 +0,0 @@
"use client"
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
import {
DRAWIO_CONFIG,
EMPTY_DRAWIO_DOCUMENT,
} from "@/lib/drawio/drawio-config"
import {
attachEditor,
detachEditor,
watchForEditorUi,
} from "@/lib/drawio/editor-bridge"
export interface DrawioExportEvent {
event: "export"
format: string
data: string
xml: string
message: { message?: string; [key: string]: unknown }
}
export interface DrawioFrameHandle {
load: (data: { xml: string }) => void
exportDiagram: (data: {
format: string
message?: string
[key: string]: unknown
}) => void
}
interface DrawioFrameProps {
src: string
/** CSS sent with the configure message (works cross-origin too) */
configCss: string
/** CSS injected once the editor is reachable directly (same origin) */
editorCss: string
onLoad?: () => void
onAutoSave?: (data: { xml?: string }) => void
onExport?: (data: DrawioExportEvent) => void
/** Called once we know whether the editor can be driven directly */
onEditorAccess?: (hasEditor: boolean) => void
}
const STYLE_ID = "next-ai-drawio-theme"
/**
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
* protocol (same messages react-drawio used). When the iframe is same-origin
* it also hands the editor object to editor-bridge for direct control.
*/
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame(
{
src,
configCss,
editorCss,
onLoad,
onAutoSave,
onExport,
onEditorAccess,
},
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const editorUiRef = useRef<any>(null)
const attachedRef = useRef(false)
const configCssRef = useRef(configCss)
configCssRef.current = configCss
const editorCssRef = useRef(editorCss)
editorCssRef.current = editorCss
// Handlers change on every render; the message listener reads refs
const handlersRef = useRef({
onLoad,
onAutoSave,
onExport,
onEditorAccess,
})
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
// draw.io's origin: messages go only there, and only its messages
// count (the frame could be navigated to another site)
// (read on use: there is no window while the page renders on the server)
const drawioOrigin = () => new URL(src, window.location.href).origin
const send = (action: string, data: Record<string, unknown> = {}) => {
iframeRef.current?.contentWindow?.postMessage(
JSON.stringify({ action, ...data }),
drawioOrigin(),
)
}
useImperativeHandle(ref, () => ({
load: ({ xml }) => send("load", { xml, autosave: 1 }),
exportDiagram: (data) => send("export", data),
}))
// Same-origin only: keep the theme CSS current without a reload
const applyThemeCss = () => {
if (!attachedRef.current) return
try {
const doc = iframeRef.current?.contentDocument
if (!doc?.head) return
let style = doc.getElementById(STYLE_ID)
if (!style) {
style = doc.createElement("style")
style.id = STYLE_ID
doc.head.appendChild(style)
}
style.textContent = editorCssRef.current
} catch {
// Cross-origin: the configure message already applied it
}
}
useEffect(() => {
applyThemeCss()
}, [editorCss])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
editorUiRef.current = null
attachedRef.current = false
let reported: boolean | null = null
const report = (value: boolean) => {
if (reported === value) return
reported = value
handlersRef.current.onEditorAccess?.(value)
}
// Watch for draw.io's scripts so the editor object can be caught
// as it starts. Stops once hooked, or when the frame turns out to
// be cross-origin.
const startedAt = Date.now()
const poll = setInterval(() => {
const frameWindow = iframe.contentWindow as any
if (!frameWindow) return
let ready = false
try {
ready =
frameWindow.location.href !== "about:blank" &&
!!frameWindow.EditorUi
} catch {
clearInterval(poll)
report(false)
return
}
if (ready) {
clearInterval(poll)
const ok = watchForEditorUi(frameWindow, (ui) => {
editorUiRef.current = ui
})
if (!ok) report(false)
} else if (Date.now() - startedAt > 60_000) {
clearInterval(poll)
}
}, 5)
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return
if (event.origin !== drawioOrigin()) return
if (typeof event.data !== "string") return
let data: any
try {
data = JSON.parse(event.data)
} catch {
return
}
const handlers = handlersRef.current
switch (data.event) {
case "init":
send("load", {
xml: EMPTY_DRAWIO_DOCUMENT,
autosave: 1,
})
break
case "configure":
send("configure", {
config: {
...DRAWIO_CONFIG,
css: configCssRef.current,
},
})
break
case "load": {
const tryAttach = () => {
const ui = editorUiRef.current
if (!ui || !iframe.contentWindow) return false
attachEditor(ui, iframe.contentWindow, iframe)
attachedRef.current = true
applyThemeCss()
report(true)
return true
}
// The editor object is usually caught before the
// first load; give it a moment, then fall back to
// draw.io's own toolbar
if (!tryAttach()) {
setTimeout(() => {
if (!tryAttach()) report(false)
}, 800)
}
handlers.onLoad?.()
break
}
case "autosave":
handlers.onAutoSave?.(data)
break
case "export":
handlers.onExport?.(data)
break
}
}
window.addEventListener("message", handleMessage)
return () => {
clearInterval(poll)
window.removeEventListener("message", handleMessage)
detachEditor()
}
}, [src])
return (
<iframe
ref={iframeRef}
src={src}
title="draw.io"
allow="clipboard-read; clipboard-write"
className="block h-full w-full border-0"
/>
)
},
)
-118
View File
@@ -1,118 +0,0 @@
import { ChevronDown, Plus } from "lucide-react"
import { useState } from "react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import {
addPage,
canManagePages,
duplicatePage,
removePage,
renamePage,
selectPage,
} from "@/lib/drawio/editor-bridge"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
/** Page switcher at the bottom of the canvas (replaces draw.io's page tabs) */
export function PageTabs() {
const dict = useDictionary()
const t = dict.workspace
const pages = useCanvasStore((s) => s.pages)
const currentPageId = useCanvasStore((s) => s.currentPageId)
const [menuFor, setMenuFor] = useState<string | null>(null)
if (!canManagePages() || pages.length === 0) return null
return (
<div
className="pointer-events-auto flex max-w-[min(640px,100%)] items-center gap-0.5 overflow-x-auto rounded-xl bg-card p-1 shadow-float"
data-testid="page-tabs"
>
{pages.map((page) => {
const isCurrent = page.id === currentPageId
if (!isCurrent) {
return (
<button
key={page.id}
type="button"
onClick={() => selectPage(page.id)}
className="h-7 max-w-40 shrink-0 truncate rounded-lg px-2.5 text-[13px] text-muted-foreground hover:bg-accent hover:text-foreground"
>
{page.name}
</button>
)
}
return (
<Popover
key={page.id}
open={menuFor === page.id}
onOpenChange={(open) =>
setMenuFor(open ? page.id : null)
}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-7 max-w-44 shrink-0 items-center gap-1 rounded-lg bg-accent px-2.5 text-[13px] font-medium text-foreground",
)}
>
<span className="truncate">{page.name}</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-44 p-1"
>
{[
{
label: t.renamePage,
run: () => renamePage(page.id),
},
{
label: t.duplicatePage,
run: () => duplicatePage(page.id),
},
...(pages.length > 1
? [
{
label: t.deletePage,
run: () => removePage(page.id),
},
]
: []),
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
setMenuFor(null)
item.run()
}}
className="w-full rounded-md px-2.5 py-1.5 text-left text-[13px] hover:bg-accent"
>
{item.label}
</button>
))}
</PopoverContent>
</Popover>
)
})}
<IconButton
label={t.addPage}
size="sm"
tooltipSide="top"
onClick={addPage}
>
<Plus />
</IconButton>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
import { Sparkles } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const BUTTON_HEIGHT = 32
const GAP = 10
/**
* "Ask AI to change this" next to the selected shapes. Clicking it moves
* focus to the chat input, where the selection already shows as a chip.
*/
export function SelectionAsk({
width,
height,
hidden,
}: {
/** Size of the canvas area, for keeping the button inside it */
width: number
height: number
hidden: boolean
}) {
const dict = useDictionary()
const rect = useCanvasStore((s) => s.selectionRect)
const selection = useCanvasStore((s) => s.selection)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const focusComposer = useUiStore((s) => s.focusComposer)
if (hidden || !rect || selection.length === 0 || isFreehand) return null
// Selection scrolled out of view
if (
rect.x + rect.width < 0 ||
rect.y + rect.height < 0 ||
rect.x > width ||
rect.y > height
) {
return null
}
// Above the selection's top-right corner, kept inside the canvas
const approxWidth = 168
const left = Math.min(
Math.max(8, rect.x + rect.width - approxWidth / 2),
width - approxWidth - 8,
)
const above = rect.y - BUTTON_HEIGHT - GAP
const top =
above > 64
? above
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
return (
<button
type="button"
onClick={focusComposer}
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
style={{ left, top }}
data-testid="selection-ask"
>
<Sparkles className="size-4 text-marker-ink" />
{dict.workspace.askAi}
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
{modKey}/
</kbd>
</button>
)
}
+227
View File
@@ -0,0 +1,227 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
isNew?: boolean
}
function ExampleCard({
icon,
title,
description,
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
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"
}`}
>
<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"
}`}
>
{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">
{dict.common.new}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
)
}
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handlePdfExample = async () => {
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/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(dict.errors.failedToLoadExample, error)
}
}
return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* 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">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
</>
)}
{/* Examples grid */}
<div className="space-y-3">
{!minimal && (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
)}
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
{dict.examples.cachedNote}
</p>
</div>
</div>
)
}
+608
View File
@@ -0,0 +1,608 @@
"use client"
import {
Download,
History,
Image as ImageIcon,
Link,
Loader2,
Send,
} from "lucide-react"
import type React from "react"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_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`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[]
errors: string[]
}
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
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
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string
error?: Error | null
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
input,
status,
onSubmit,
onChange,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {},
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
},
}))
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
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 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import ExamplePanel from "@/components/chat-example-panel"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatLobbyProps {
sessions: SessionMetadata[]
onSelectSession: (id: string) => void
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
examples?: {
quickExamples?: string
}
common: {
delete: string
cancel: string
}
}
}
// Helper to format session date
function formatSessionDate(
timestamp: number,
dict?: { justNow?: string },
): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffMins < 1) return dict?.justNow || "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
dict,
}: ChatLobbyProps) {
// Track whether examples section is expanded (collapsed by default when there's history)
const [examplesExpanded, setExamplesExpanded] = useState(false)
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
// Search filter for history
const [searchQuery, setSearchQuery] = useState("")
const hasHistory = sessions.length > 0
if (!hasHistory) {
// Show full examples when no history
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
{/* Collapsible Examples Section */}
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.sessionHistory?.deleteTitle ||
"Delete this chat?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.sessionHistory?.deleteDescription ||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (sessionToDelete && onDeleteSession) {
onDeleteSession(sessionToDelete)
}
setDeleteDialogOpen(false)
setSessionToDelete(null)
}}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
-206
View File
@@ -1,206 +0,0 @@
"use client"
import { Bookmark, Plus } 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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import {
createTemplate,
type TemplateCreateInput,
} from "@/lib/template-storage"
interface TemplateCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
initialPrompt?: string
}
export function TemplateCreateDialog({
open,
onOpenChange,
onSuccess,
initialPrompt = "",
}: TemplateCreateDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when dialog opens with the latest initialPrompt
useEffect(() => {
if (open) {
setTitle("")
setDescription("")
setPrompt(initialPrompt)
setPinned(false)
setError(null)
}
}, [open, initialPrompt])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const input: TemplateCreateInput = {
prompt: trimmedPrompt,
title: title.trim() || undefined,
description: description.trim() || undefined,
pinned,
}
const template = await createTemplate(input)
if (template) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.createFailed)
}
} catch (err) {
console.error("Failed to create template:", err)
setError(dict.templates.createFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
{dict.templates.createTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.createDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label htmlFor="prompt" className="text-foreground">
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label htmlFor="title" className="text-foreground">
{dict.templates.titleLabel}
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.templates.createButton}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-218
View File
@@ -1,218 +0,0 @@
"use client"
import { Bookmark, Edit2 } 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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { type Template, updateTemplate } from "@/lib/template-storage"
interface TemplateEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void
}
export function TemplateEditDialog({
open,
onOpenChange,
template,
onSuccess,
}: TemplateEditDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form each time the dialog opens, dropping any cancelled edits
useEffect(() => {
if (open && template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined,
pinned,
}
const updated = await updateTemplate(template.id, updates)
if (updated) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.updateFailed)
}
} catch (err) {
console.error("Failed to update template:", err)
setError(dict.templates.updateFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit2 className="w-5 h-5" />
{dict.templates.editTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.editDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label
htmlFor="edit-prompt"
className="text-foreground"
>
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="edit-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-title"
className="text-foreground"
>
{dict.templates.titleLabel}
</Label>
<Input
id="edit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="edit-pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="edit-pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.common.save}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-651
View File
@@ -1,651 +0,0 @@
"use client"
import {
Bookmark,
Copy,
Download,
Edit2,
FileText,
Plus,
Search,
Trash2,
Upload,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary"
import {
deleteTemplate,
duplicateTemplate,
exportTemplates,
getAllTemplates,
importTemplates,
incrementClickCount,
incrementRunCount,
searchTemplates,
type Template,
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { useUiStore } from "@/stores/ui-store"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
/** Skip the title block (the surrounding dialog already has one) */
hideHeader?: boolean
}
function formatLastUsed(timestamp: number, neverUsedText: string): string {
if (!timestamp) return neverUsedText
const now = Date.now()
const diffMs = now - timestamp
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
try {
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
if (diffMins < 1) return rtf.format(0, "minute")
if (diffMins < 60) return rtf.format(-diffMins, "minute")
if (diffHours < 24) return rtf.format(-diffHours, "hour")
if (diffDays < 7) return rtf.format(-diffDays, "day")
} catch {
// Fallback if Intl.RelativeTimeFormat is not available
if (diffMins < 1) return "<1m ago"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
}
return new Date(timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function TemplatePanel({
setInput,
onSendTemplate,
currentInput = "",
hideHeader = false,
}: TemplatePanelProps) {
const dict = useDictionary()
const templatesVersion = useUiStore((s) => s.templatesVersion)
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
null,
)
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMessage, setImportMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
const loadTemplates = useCallback(async () => {
const result = await getAllTemplates()
setTemplates(result)
setLoading(false)
}, [])
// Filter templates by search query
const filteredTemplates = searchQuery.trim()
? searchTemplates(templates, searchQuery)
: templates
// Reload when a template is saved elsewhere, e.g. from the chat input
useEffect(() => {
loadTemplates()
}, [loadTemplates, templatesVersion])
const handleCreateSuccess = () => {
loadTemplates()
}
const handleEditSuccess = () => {
loadTemplates()
}
const handleEdit = (template: Template) => {
setTemplateToEdit(template)
setEditDialogOpen(true)
}
const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate(
template.id,
dict.templates.copySuffix || "(copy)",
)
if (duplicated) {
loadTemplates()
}
}
const handleDeleteClick = (template: Template) => {
setTemplateToDelete(template)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
if (!templateToDelete) return
const success = await deleteTemplate(templateToDelete.id)
if (success) {
loadTemplates()
}
setDeleteDialogOpen(false)
setTemplateToDelete(null)
}
const handleTogglePin = async (template: Template) => {
const updated = await updateTemplate(template.id, {
pinned: !template.pinned,
})
if (updated) {
loadTemplates()
}
}
// Handle template card click - send directly or show confirmation
const handleTemplateClick = async (template: Template) => {
// If there's unsent content in the input, show confirmation dialog
if (currentInput.trim()) {
setTemplateToSend(template)
setConfirmSendDialogOpen(true)
return
}
// No unsent content, send directly
await sendTemplate(template)
}
// Actually send the template
const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (onSendTemplate) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
// Call the send callback
onSendTemplate(template)
} else {
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Handle confirmation dialog - user confirmed to send template
const handleConfirmSend = async () => {
if (!templateToSend) return
await sendTemplate(templateToSend)
}
// Handle cancel - close dialog without sending
const handleCancelSend = () => {
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Export templates to JSON file
const handleExport = () => {
if (templates.length === 0) {
setImportMessage({
type: "error",
text: dict.templates.exportEmpty || "No templates to export",
})
return
}
try {
const exportData = exportTemplates(templates)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
// Local date as YYYY-MM-DD (toISOString would give UTC)
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setImportMessage({
type: "success",
text: dict.templates.exportSuccess.replace(
"{count}",
String(templates.length),
),
})
setTimeout(() => setImportMessage(null), 3000)
} catch (error) {
console.error("Failed to export templates:", error)
setImportMessage({
type: "error",
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
})
}
}
// Import templates from JSON file
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
setImportMessage({
type: "error",
text:
dict.templates.importNoFile || "Please select a JSON file",
})
return
}
try {
const text = await file.text()
const data = JSON.parse(text)
// Validate import data
const validation = validateImportData(data)
if (!validation.valid) {
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
validation.error || "Invalid data",
),
})
return
}
// Import templates with dedup-append strategy
const result = await importTemplates(data.templates, templates)
// Reload template list
await loadTemplates()
setImportMessage({
type: "success",
text: dict.templates.importSuccess
.replace("{imported}", String(result.imported))
.replace("{skipped}", String(result.skipped)),
})
setTimeout(() => setImportMessage(null), 5000)
} catch (error) {
console.error("Failed to import templates:", error)
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
error instanceof Error ? error.message : "Unknown error",
),
})
} finally {
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
// Shared by the empty state and the list, so import works in both
const importInput = (
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
)
const importMessageBox = importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
<div className="py-6 px-2 animate-fade-in">
{!hideHeader && (
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
)}
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{dict.templates.emptyTitle}
</p>
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
{dict.templates.emptyDescription}
</p>
<button
type="button"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
{importMessageBox && (
<div className="mt-3">{importMessageBox}</div>
)}
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
</div>
</div>
)
}
// Template list
return (
<div className="py-2 px-2 animate-fade-in">
<div className="space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={dict.templates.searchPlaceholder}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{dict.templates.createButton}
</button>
<div className="flex-1" />
<button
type="button"
onClick={handleExport}
disabled={templates.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={dict.templates.exportTemplates}
>
<Download className="w-3.5 h-3.5" />
{dict.templates.exportTemplates}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={dict.templates.importTemplates}
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
</div>
{/* Import message */}
{importMessageBox}
<div className="space-y-2">
{loading
? // Loading skeleton
Array.from({ length: 3 }).map((_, i) => (
<div
key={`skeleton-${String(i)}`}
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</div>
))
: filteredTemplates.length === 0
? // Search empty state
!loading && (
<div className="flex flex-col items-center justify-center py-6 px-4">
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground text-center">
{dict.templates.searchNoResults}
</p>
</div>
)
: filteredTemplates.map((template) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
<div
key={template.id}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() =>
handleTemplateClick(template)
}
onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
handleTemplateClick(template)
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">
{template.title}
</div>
{template.pinned && (
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
)}
</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">
{template.description}
</div>
)}
</div>
{/* Actions and stats */}
<div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
: dict.templates.neverUsed}
</div>
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleTogglePin(template)
}}
className={`p-1.5 rounded-lg transition-all ${
template.pinned
? "text-primary hover:text-primary/80 hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
title={
template.pinned
? dict.templates
.unpin || "Unpin"
: dict.templates.pin ||
"Pin"
}
>
<Bookmark
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleEdit(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={dict.common.edit}
>
<Edit2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDuplicate(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={
dict.templates.duplicate ||
"Duplicate"
}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
<TemplateEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
template={templateToEdit}
onSuccess={handleEditSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.deleteTitle ||
"Delete this template?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.deleteDescription ||
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirm Send Dialog - when there's unsent input */}
<AlertDialog
open={confirmSendDialogOpen}
onOpenChange={setConfirmSendDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.confirmSendTitle ||
"Replace current input?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.confirmSendDescription ||
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelSend}>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton ||
"Send Template"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+250
View File
@@ -0,0 +1,250 @@
"use client"
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
interface ToolCallCardProps {
part: ToolPartLike
expandedTools: Record<string, boolean>
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
onCopy: (callId: string, text: string, isToolCall: boolean) => void
copiedToolCallId: string | null
copyFailedToolCallId: string | null
dict: {
tools: { complete: string }
chat: { copied: string; failedToCopy: string; copyResponse: string }
}
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${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.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
</div>
{op.new_xml && (
<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}
</pre>
</div>
)}
</div>
))}
</div>
)
}
export function ToolCallCard({
part,
expandedTools,
setExpandedTools,
onCopy,
copiedToolCallId,
copyFailedToolCallId,
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (input.operations && Array.isArray(input.operations)) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
}
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{dict.tools.complete}
</span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId === callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)}
{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>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : 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>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -10,8 +10,8 @@ import {
RefreshCw,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
-77
View File
@@ -1,77 +0,0 @@
import { MAX_FILES, MAX_FILE_SIZE as MAX_IMAGE_SIZE } from "@/lib/chat-helpers"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
/** Accept list for the attachment picker */
export const ATTACHMENT_ACCEPT =
"image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
/** Keep the files that can be attached; describe the rest as errors */
export function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): { validFiles: File[]; errors: string[] } {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// PDFs and text files are extracted in the browser, so only images
// have a size limit
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: MAX_IMAGE_SIZE / 1024 / 1024,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
File diff suppressed because it is too large Load Diff
-192
View File
@@ -1,192 +0,0 @@
import {
ChevronDown,
Download,
MessageSquareDashed,
PanelRightClose,
Settings2,
SquarePen,
} from "lucide-react"
import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import {
PANEL_MAX_WIDTH,
PANEL_MIN_WIDTH,
useSettingsStore,
} from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { MessageList } from "./message-list"
import { SessionMenu, useSessionTitle } from "./session-menu"
import { VersionStrip } from "./version-strip"
const DEBUG = process.env.NODE_ENV === "development"
/** Drag handle on the panel's left edge (the panel sits on the right) */
function ResizeHandle() {
const panelWidth = useSettingsStore((s) => s.panelWidth)
const setPanelWidth = useSettingsStore((s) => s.setPanelWidth)
const startRef = useRef<{ x: number; width: number } | null>(null)
return (
// biome-ignore lint/a11y/useSemanticElements: a draggable handle, not a divider line
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={panelWidth}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={PANEL_MAX_WIDTH}
tabIndex={-1}
className="absolute top-4 -left-1.5 bottom-4 z-10 w-3 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:bg-foreground/0 after:transition-colors hover:after:bg-foreground/15"
onPointerDown={(e) => {
e.preventDefault()
const panel = e.currentTarget.parentElement
startRef.current = {
x: e.clientX,
width: panel?.getBoundingClientRect().width ?? 384,
}
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const start = startRef.current
if (!start) return
// Dragging left widens the panel
setPanelWidth(start.width - (e.clientX - start.x))
}}
onPointerUp={(e) => {
startRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}}
/>
)
}
function EmptyThread() {
const dict = useDictionary()
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center">
<MessageSquareDashed className="size-5 text-faint" />
<p className="max-w-60 text-[13px] text-muted-foreground">
{dict.chat.emptyThread}
</p>
</div>
)
}
/** The floating chat panel: header, versions, conversation, message box */
export function ChatPanel({
mobile = false,
onOpenFile,
}: {
mobile?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const engine = useChatEngine()
const { loadDiagram, isDrawioReady } = useDiagram()
// An external draw.io has no app toolbar (and so no Export button there)
const hasEditor = useCanvasStore((s) => s.hasEditor)
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const title = useSessionTitle()
const panelWidth = useSettingsStore((s) => s.panelWidth)
const openSettings = useUiStore((s) => s.openSettings)
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
return (
<aside
className={cn(
"flex flex-col bg-card",
mobile
? "absolute inset-0"
: "absolute top-3 right-3 bottom-3 z-20 rounded-2xl shadow-float animate-panel-in",
)}
style={mobile ? undefined : { width: panelWidth }}
aria-label={dict.nav.aiChat}
data-testid="chat-panel"
>
<header className="flex h-[52px] shrink-0 items-center gap-1 pr-2 pl-3">
<BrandMark />
<SessionMenu onOpenFile={onOpenFile}>
<button
type="button"
className="ml-1 flex min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 hover:bg-accent"
data-testid="session-title"
>
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]">
{title}
</span>
<ChevronDown className="size-4 shrink-0 text-faint" />
</button>
</SessionMenu>
<div className="ml-auto flex items-center">
{!hasEditor && isDrawioReady && (
<IconButton
label={dict.workspace.export}
onClick={() => setSaveDialogOpen(true)}
data-testid="panel-export"
>
<Download />
</IconButton>
)}
<IconButton
label={dict.nav.newChat}
onClick={engine.newChat}
disabled={engine.isBusy}
data-testid="new-chat-button"
>
<SquarePen />
</IconButton>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
{!mobile && (
<IconButton
label={dict.nav.hidePanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(false)}
>
<PanelRightClose />
</IconButton>
)}
</div>
</header>
<VersionStrip />
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
</div>
{DEBUG && (
<DevXmlSimulator
setMessages={engine.setMessages as any}
onDisplayChart={(xml) => loadDiagram(xml, true)}
/>
)}
<div className="shrink-0 p-2.5 pt-1">
<Composer
placeholder={
engine.messages.length > 0 &&
engine.chatSelection.length === 0
? dict.chat.placeholderFollowUp
: undefined
}
/>
</div>
{!mobile && <ResizeHandle />}
</aside>
)
}
-175
View File
@@ -1,175 +0,0 @@
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
import { describeChanges } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
function Sheet({
label,
svg,
loadingText,
emptyText,
}: {
label: string
svg: string | null | undefined
loadingText: string
emptyText: string
}) {
return (
<figure className="flex min-w-0 flex-1 flex-col gap-2">
<figcaption className="text-xs font-medium text-muted-foreground">
{label}
</figcaption>
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
{svg === "" ? (
<span className="text-xs text-faint">{emptyText}</span>
) : svg ? (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={svg}
alt=""
className="max-h-full max-w-full object-contain"
/>
) : (
<Shimmer as="span" className="text-xs">
{loadingText}
</Shimmer>
)}
</div>
</figure>
)
}
/** A saved version next to the current canvas, with restore */
export function CompareDialog() {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const { getVersionSvg, chartXML } = useDiagram()
const versionId = useUiStore((s) => s.compareVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const closeCompare = useUiStore((s) => s.closeCompare)
const versions = useVersionsStore((s) => s.versions)
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
const index = versions.findIndex((v) => v.id === versionId)
const version = index >= 0 ? versions[index] : null
// Whether the canvas is this version, on every page
const same = useMemo(
() => !version || isSameDocument(version.xml, chartXML),
[version, chartXML],
)
// What the canvas has changed since this version on the first page
// (null: nothing there)
const changes = useMemo(() => {
if (!version || same) return null
const { summary } = diffDiagrams(version.xml, chartXML)
return Object.values(summary).some((n) => n > 0) ? summary : null
}, [version, chartXML, same])
// Picture of the canvas as it is now
useEffect(() => {
if (!versionId) return
let cancelled = false
setCurrentSvg(null)
getVersionSvg().then((svg) => {
if (!cancelled) setCurrentSvg(svg)
})
return () => {
cancelled = true
}
}, [versionId])
return (
<Dialog
open={!!version}
onOpenChange={(open) => !open && closeCompare()}
>
<DialogContent className="gap-5 sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t.compareTitle}</DialogTitle>
<DialogDescription>
{!version
? ""
: same
? t.isOnCanvas
: changes
? `${formatMessage(t.compareSince, { n: index + 1 })} ${describeChanges(changes, t)}`
: formatMessage(t.differsElsewhere, {
n: index + 1,
})}
</DialogDescription>
</DialogHeader>
{version && (
<div className="flex flex-col gap-4 sm:flex-row">
<Sheet
label={formatMessage(t.versionLabel, {
n: index + 1,
})}
svg={version.svg}
loadingText={t.rendering}
emptyText={t.noPreview}
/>
<Sheet
label={t.currentCanvas}
svg={currentSvg}
loadingText={t.rendering}
emptyText={t.noPreview}
/>
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={index <= 0}
onClick={() => openCompare(versions[index - 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.previous}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
disabled={index < 0 || index >= versions.length - 1}
onClick={() => openCompare(versions[index + 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.next}
>
<ChevronRight className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{index + 1} / {versions.length}
</span>
<button
type="button"
disabled={!version || same || engine.isBusy}
onClick={() => {
if (!version) return
engine.restoreVersion(version.id)
closeCompare()
}}
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
data-testid="compare-restore"
>
<RotateCcw className="size-4" />
{formatMessage(t.restoreVersion, { n: index + 1 })}
</button>
</div>
</DialogContent>
</Dialog>
)
}
-530
View File
@@ -1,530 +0,0 @@
import {
ArrowUp,
BookMarked,
BookmarkPlus,
Globe,
Paperclip,
Plus,
Square,
SquareDashedMousePointer,
X,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { modKey } from "@/lib/platform"
import { extractUrlContent } from "@/lib/url-utils"
import { cn } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine"
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
return
}
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="list-inside list-disc text-xs text-muted-foreground">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
function MenuItem({
icon,
children,
onClick,
disabled,
disabledHint,
}: {
icon: React.ReactNode
children: React.ReactNode
onClick: () => void
disabled?: boolean
/** Why the item can't be used now, shown under it while disabled */
disabledHint?: string
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group/item flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] hover:bg-accent disabled:pointer-events-none [&_svg]:mt-px [&_svg]:size-4 [&_svg]:text-muted-foreground"
>
<span className="contents group-disabled/item:[&>svg]:opacity-40">
{icon}
</span>
<span className="min-w-0">
<span className="block group-disabled/item:opacity-40">
{children}
</span>
{disabled && disabledHint && (
<span className="block text-xs text-muted-foreground">
{disabledHint}
</span>
)}
</span>
</button>
)
}
/**
* The message box: text, attachments, the selected shapes, the model picker
* and send/stop. `variant="hero"` is the large box on the start screen.
*/
export function Composer({
variant = "panel",
placeholder,
autoFocus = false,
}: {
variant?: "panel" | "hero"
placeholder?: string
autoFocus?: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const {
input,
setInput,
files,
setFiles,
pdfData,
urlData,
setUrlData,
isBusy,
error,
chatSelection,
dismissSelection,
modelConfig,
} = engine
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
const openSettings = useUiStore((s) => s.openSettings)
const focusToken = useUiStore((s) => s.focusComposerToken)
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const isHero = variant === "hero"
// Allow a retry when the last request failed, even mid-stream
const isDisabled = isBusy && !error
const canSend =
!isDisabled && !engine.isExtractingAttachments && !!input.trim()
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [input, adjustHeight])
// Focus requests from elsewhere (new chat, "ask AI" button, ⌘/)
useEffect(() => {
if (focusToken === 0 && !autoFocus) return
const timer = setTimeout(() => textareaRef.current?.focus(), 60)
return () => clearTimeout(timer)
}, [focusToken, autoFocus])
const addFiles = (newFiles: File[]) => {
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) setFiles([...files, ...validFiles])
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
if (canSend) engine.submit()
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length === 0) return
const imageFiles = imageItems
.map((item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
})
.filter((f): f is File => f !== null)
addFiles(imageFiles)
}
const handleUrlExtract = async (url: string) => {
setIsExtractingUrl(true)
// Functional updates, so a removal or send made while extracting is
// not overwritten when the request finishes
try {
setUrlData((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
setUrlData((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (err) {
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
const selectionLabel =
chatSelection.length === 1
? formatMessage(dict.selection.one, {
label:
chatSelection[0].label ||
(chatSelection[0].isEdge
? dict.selection.connector
: dict.selection.shape),
})
: formatMessage(dict.selection.many, {
count: chatSelection.length,
})
const hasChips =
chatSelection.length > 0 || files.length > 0 || urlData.size > 0
return (
<form
id={isHero ? "hero-chat-form" : "chat-form"}
onSubmit={(e) => {
e.preventDefault()
if (canSend) engine.submit()
}}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
if (!isDisabled) addFiles(Array.from(e.dataTransfer.files))
}}
className={cn(
"group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150",
"focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]",
isHero && "rounded-[18px] border-transparent shadow-pop",
isDragging &&
"border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]",
)}
>
{hasChips && (
<div className="flex flex-wrap items-center gap-1.5 px-2.5 pt-2.5">
{chatSelection.length > 0 && (
<span
className="inline-flex max-w-full items-center gap-1.5 rounded-lg bg-marker-soft py-1 pr-1 pl-2 text-xs font-medium text-marker-ink"
data-testid="selection-chip"
>
<SquareDashedMousePointer className="size-3.5 shrink-0" />
<span className="truncate">{selectionLabel}</span>
<button
type="button"
onClick={dismissSelection}
aria-label={dict.selection.remove}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
)}
{(files.length > 0 || urlData.size > 0) && (
<div className="w-full">
<FilePreviewList
files={files}
onRemoveFile={(file) =>
setFiles(files.filter((f) => f !== file))
}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={(url) =>
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
}
/>
</div>
)}
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
placeholder ??
(chatSelection.length > 0
? dict.selection.placeholder
: dict.chat.placeholder)
}
disabled={isDisabled}
aria-label={dict.chat.inputLabel}
data-testid="chat-input"
rows={isHero ? 3 : 2}
className={cn(
"block w-full resize-none bg-transparent text-foreground outline-none placeholder:text-faint scrollbar-thin disabled:opacity-60",
isHero
? "min-h-[84px] px-[18px] pt-4 pb-1 text-[15px] leading-relaxed"
: "min-h-[52px] px-3.5 pt-3 pb-1 text-sm",
)}
/>
<div
className={cn(
"flex items-center gap-0.5",
isHero ? "px-2.5 pt-1 pb-2.5" : "px-1.5 pt-0.5 pb-1.5",
)}
>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<IconButton
label={dict.chat.attach}
tooltipSide="top"
disabled={isDisabled}
data-testid="composer-add"
>
<Plus />
</IconButton>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-64 p-1"
>
<MenuItem
icon={<Paperclip />}
onClick={() => {
setAddOpen(false)
fileInputRef.current?.click()
}}
>
{dict.chat.uploadFile}
</MenuItem>
<MenuItem
icon={<Globe />}
onClick={() => {
setAddOpen(false)
setShowUrlDialog(true)
}}
>
{dict.chat.ExtractURL}
</MenuItem>
<MenuItem
icon={<BookmarkPlus />}
disabled={!input.trim()}
disabledHint={dict.templates.saveDisabledHint}
onClick={() => {
setAddOpen(false)
setSaveTemplateOpen(true)
}}
>
{dict.templates.saveAsTemplate}
</MenuItem>
</PopoverContent>
</Popover>
<IconButton
label={dict.templates.myTemplates}
tooltipSide="top"
disabled={isDisabled}
onClick={() => setTemplatesOpen(true)}
data-testid="composer-templates"
>
<BookMarked />
</IconButton>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
<ModelSelector
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId}
onConfigure={() => openSettings("models")}
disabled={isDisabled}
showUnvalidatedModels={
modelConfig.showUnvalidatedModels
}
/>
{!isBusy && input.trim() && (
<kbd
className="hidden shrink-0 font-sans text-[11px] text-faint sm:inline"
aria-hidden
>
{sendShortcut === "enter" ? "↵" : `${modKey}↵`}
</kbd>
)}
{isBusy ? (
<button
type="button"
onClick={engine.stop}
aria-label={dict.chat.stopGeneration}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background transition-opacity hover:opacity-85"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
disabled={!canSend}
aria-label={dict.chat.send}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-[opacity,background-color] hover:opacity-90 disabled:bg-muted disabled:text-faint"
>
<ArrowUp className="size-4 stroke-[2.25]" />
</button>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={ATTACHMENT_ACCEPT}
data-testid="attachment-input"
multiple
disabled={isDisabled}
onChange={(e) => {
addFiles(Array.from(e.target.files || []))
e.target.value = ""
}}
/>
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
<TemplateCreateDialog
open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen}
onSuccess={() => {
setSaveTemplateOpen(false)
bumpTemplates()
}}
initialPrompt={input.trim()}
/>
<Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}>
<DialogContent className="flex max-h-[80vh] flex-col gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>{dict.templates.myTemplates}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel
hideHeader
setInput={(value) => {
setInput(value)
setTemplatesOpen(false)
}}
currentInput={input}
onSendTemplate={(template) => {
setTemplatesOpen(false)
engine.sendTemplate(template.prompt)
}}
/>
</div>
</DialogContent>
</Dialog>
</form>
)
}
-285
View File
@@ -1,285 +0,0 @@
import { FileText, FileUp, History, Pencil } from "lucide-react"
import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { SessionMenu } from "./session-menu"
interface Example {
key: string
title: string
description: string
/** Picture of the result (or of the input, for "replicate" examples) */
image?: string
prompt: string
/** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string }
}
async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob()
return new File([blob], file.name, { type: file.type })
}
/** One start-screen choice: a small picture, a title and one line */
function ExampleCard({
onClick,
title,
description,
picture,
plain,
testId,
}: {
onClick: () => void
title: string
description: string
picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
plain?: boolean
testId?: string
}) {
return (
<button
type="button"
onClick={onClick}
className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId}
>
<span
className={cn(
"flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white",
)}
>
{picture}
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
</span>
</button>
)
}
/**
* Start screen over an empty canvas: one large message box and the cached
* examples (they answer instantly without calling a model).
*/
export function LobbyHero({
onDrawYourself,
onOpenFile,
compact = false,
}: {
onDrawYourself: () => void
onOpenFile: () => void
/** Phone layout: no example pictures, tighter spacing */
compact?: boolean
}) {
const dict = useDictionary()
const t = dict.lobby
const engine = useChatEngine()
// Prompts and file names must stay as they are: cached responses match them
const examples: Example[] = [
{
key: "paper",
title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription,
prompt: "Summarize this paper as a diagram",
file: {
path: "/chain-of-thought.txt",
name: "chain-of-thought.txt",
type: "text/plain",
},
},
{
key: "animated",
title: dict.examples.animatedDiagram,
description: dict.examples.animatedDescription,
image: "/animated_connectors.svg",
prompt: "Give me a **animated connector** diagram of transformer's architecture",
},
{
key: "aws",
title: dict.examples.awsArchitecture,
description: dict.examples.awsDescription,
image: "/aws_demo.svg",
prompt: "Replicate this in aws style",
file: {
path: "/architecture.png",
name: "architecture.png",
type: "image/png",
},
},
{
key: "flowchart",
title: dict.examples.replicateFlowchart,
description: dict.examples.replicateDescription,
image: "/example.png",
prompt: "Replicate this flowchart.",
file: {
path: "/example.png",
name: "example.png",
type: "image/png",
},
},
{
key: "cat",
title: dict.examples.creativeDrawing,
description: dict.examples.creativeDescription,
image: "/cat_demo.svg",
prompt: "Draw a cat for me",
},
]
const pickExample = async (example: Example) => {
engine.setInput(example.prompt)
if (!example.file) {
engine.setFiles([])
return
}
try {
engine.setFiles([await loadExampleFile(example.file)])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div
className={cn(
"mx-auto w-full",
compact ? "max-w-xl px-4 py-8" : "max-w-[720px] px-6 py-10",
)}
data-testid="lobby-hero"
>
<h1
className={cn(
"text-center font-semibold tracking-[-0.025em] text-foreground",
compact
? "text-[26px] leading-tight"
: "text-[34px] leading-[1.15]",
)}
>
{t.title}
</h1>
<p
className={cn(
"mx-auto mt-2.5 max-w-[34em] text-center text-muted-foreground",
compact ? "mb-5 text-[13px]" : "mb-6 text-[14.5px]",
)}
>
{t.lede}
</p>
<Composer
variant="hero"
autoFocus={!compact}
placeholder={t.placeholder}
/>
<div
className={cn(
"mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-3",
)}
>
{examples.map((example) =>
compact ? (
<button
key={example.key}
type="button"
onClick={() => pickExample(example)}
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-left hover:border-foreground/25"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{example.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{example.description}
</span>
</span>
</button>
) : (
<ExampleCard
key={example.key}
onClick={() => pickExample(example)}
title={example.title}
description={example.description}
testId={`example-${example.key}`}
picture={
example.image ? (
// biome-ignore lint/performance/noImgElement: static preview
<img
src={getAssetUrl(example.image)}
alt=""
className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/>
) : (
<FileText className="size-5 text-muted-foreground" />
)
}
plain={!example.image}
/>
),
)}
{!compact && (
<ExampleCard
onClick={onOpenFile}
title={dict.workspace.openFile}
description={t.openFileHint}
testId="lobby-open-file"
picture={
<FileUp className="size-5 text-muted-foreground" />
}
plain
/>
)}
</div>
<p className="mt-3 text-center text-xs text-faint">
{dict.examples.cachedNote}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}>
<button
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<History className="size-4" />
{t.recent}
</button>
</SessionMenu>
)}
{compact && (
<button
type="button"
onClick={onOpenFile}
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<FileUp className="size-4" />
{dict.workspace.openFile}
</button>
)}
<button
type="button"
onClick={onDrawYourself}
className="inline-flex items-center gap-1.5 hover:text-foreground"
data-testid="draw-yourself"
>
<Pencil className="size-4" />
{t.drawYourself}
</button>
</div>
</div>
)
}
-659
View File
@@ -1,659 +0,0 @@
import type { UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
Check,
ChevronDown,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { copyText } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import {
getMessageTextContent,
getUserOriginalText,
splitTextIntoFileSections,
type TextSection,
} from "./message-text"
import { ToolActivity } from "./tool-activity"
function ActionButton({
label,
onClick,
active,
children,
}: {
label: string
onClick: () => void
active?: boolean
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={cn(
"inline-flex size-7 items-center justify-center rounded-lg text-faint transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-3.5",
active && "text-foreground",
)}
>
{children}
</button>
)
}
function AttachedSection({ section }: { section: TextSection }) {
const [open, setOpen] = useState(false)
const dict = useDictionary()
const Icon =
section.fileType === "pdf"
? FileText
: section.fileType === "url"
? Link
: FileCode
const chars =
section.charCount && section.charCount >= 1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{section.filename}
</span>
<span className="shrink-0 text-[11px] text-faint">
{chars} {dict.file.chars}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap border-t border-border px-2.5 py-2 text-xs text-muted-foreground scrollbar-thin">
{section.content}
</pre>
)}
</div>
)
}
function MessageText({ text, isUser }: { text: string; isUser: boolean }) {
const sections = splitTextIntoFileSections(text)
return (
<div className="space-y-2">
{sections.map((section, index) =>
section.type === "text" ? (
<div
key={index}
className={cn(
"prose prose-sm max-w-none break-words text-[14px] leading-relaxed text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none",
!isUser && "dark:prose-invert",
isUser && "dark:prose-invert",
)}
>
<ReactMarkdown>{section.content}</ReactMarkdown>
</div>
) : (
<AttachedSection key={index} section={section} />
),
)}
</div>
)
}
function UserMessage({
message,
index,
isLast,
}: {
message: UIMessage
index: number
isLast: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState("")
const [copied, setCopied] = useState(false)
const [saveTemplate, setSaveTemplate] = useState(false)
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = getMessageTextContent(message)
const canEdit = isLast && !engine.isBusy
useEffect(() => {
if (editing) textareaRef.current?.focus()
}, [editing])
const startEdit = () => {
if (!canEdit) return
setEditText(getUserOriginalText(message))
setEditing(true)
}
const submitEdit = () => {
if (!editText.trim()) return
engine.editMessage(index, editText.trim())
setEditing(false)
}
if (editing) {
return (
<div className="ml-6 flex flex-col gap-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={Math.min(editText.split("\n").length + 1, 6)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false)
else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
submitEdit()
}
}}
className="w-full resize-none rounded-xl border border-foreground/20 bg-card px-3 py-2.5 text-sm outline-none focus:shadow-[0_0_0_4px_var(--accent)]"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="h-7 rounded-lg px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{dict.common.cancel}
</button>
<button
type="button"
onClick={submitEdit}
disabled={!editText.trim()}
className="h-7 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground disabled:opacity-40"
>
{dict.chat.saveAndSubmit}
</button>
</div>
</div>
)
}
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
url: string
}[]
return (
<div className="group/message flex items-start justify-end gap-1">
<div className="mt-1 flex shrink-0 items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{canEdit && (
<ActionButton
label={dict.chat.editMessage}
onClick={startEdit}
>
<Pencil />
</ActionButton>
)}
<ActionButton
label={copied ? dict.chat.copied : dict.chat.copyResponse}
onClick={async () => {
const ok = await copyText(text)
if (!ok) toast.error(dict.chat.failedToCopyDetail)
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
<ActionButton
label={dict.templates.saveAsTemplate}
onClick={() => setSaveTemplate(true)}
>
<BookmarkPlus />
</ActionButton>
</div>
<div
className={cn(
"min-w-0 max-w-[85%] rounded-2xl rounded-br-md bg-secondary px-3.5 py-2.5",
canEdit &&
"cursor-pointer transition-colors hover:bg-interactive-active",
)}
role={canEdit ? "button" : undefined}
tabIndex={canEdit ? 0 : undefined}
onClick={startEdit}
onKeyDown={(e) => {
if (canEdit && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
startEdit()
}
}}
title={canEdit ? dict.chat.clickToEdit : undefined}
>
{images.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, i) => (
<Image
key={i}
src={image.url}
width={96}
height={72}
alt={dict.chat.attachedImage}
className="h-[72px] w-24 rounded-lg border border-border bg-white object-cover"
/>
))}
</div>
)}
{text && <MessageText text={text} isUser />}
</div>
{saveTemplate && (
<TemplateCreateDialog
open
onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => {
setSaveTemplate(false)
bumpTemplates()
}}
initialPrompt={getUserOriginalText(message)}
/>
)}
</div>
)
}
function AssistantMessage({
message,
index,
isLastMessage,
isLastAssistant,
}: {
message: UIMessage
index: number
isLastMessage: boolean
isLastAssistant: boolean
}) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const engine = useChatEngine()
const [copied, setCopied] = useState(false)
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
const parts = message.parts ?? []
const isCachedExample = parts.some((p: any) =>
p.toolCallId?.startsWith("cached-"),
)
const text = getMessageTextContent(message)
const submitFeedback = async (value: "good" | "bad") => {
if (feedback === value) {
setFeedback(null)
return
}
setFeedback(value)
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId: message.id,
feedback: value,
sessionId: engine.langfuseSessionId,
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback)
setFeedback(null)
}
}
// Consecutive text parts render together; tools render in place
const blocks: { kind: "text" | "tool" | "reasoning"; index: number }[] = []
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex })
else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex })
else if (part.type === "text") {
const last = blocks[blocks.length - 1]
if (last?.kind !== "text")
blocks.push({ kind: "text", index: partIndex })
}
})
const textRun = (start: number) => {
const out: string[] = []
for (let i = start; i < parts.length && parts[i].type === "text"; i++) {
out.push((parts[i] as { text: string }).text)
}
return out.join("\n")
}
return (
<div className="group/message space-y-2.5">
{blocks.map((block) => {
const part = parts[block.index]
if (block.kind === "reasoning") {
const isStreamingReasoning =
engine.status === "streaming" &&
isLastMessage &&
block.index === parts.length - 1
return (
<Reasoning
key={`r-${block.index}`}
className="w-full"
isStreaming={isStreamingReasoning}
defaultOpen={!isRestored}
>
<ReasoningTrigger
getThinkingMessage={thinkingMessage}
/>
<ReasoningContent>
{(part as { text: string }).text}
</ReasoningContent>
</Reasoning>
)
}
if (block.kind === "tool") {
const toolPart = part as unknown as ToolPartLike
const validationState =
engine.validationStates[toolPart.toolCallId]
return (
<div key={`t-${block.index}`} className="space-y-2">
<ToolActivity
part={toolPart}
isLastMessage={isLastMessage}
/>
{toolPart.type === "tool-display_diagram" &&
validationState && (
<ValidationCard
state={validationState}
onImproveWithSuggestions={
engine.improveWithSuggestions
}
/>
)}
</div>
)
}
const run = textRun(block.index)
if (!run.trim()) return null
return (
<MessageText
key={`x-${block.index}`}
text={run}
isUser={false}
/>
)
})}
{!(engine.isBusy && isLastMessage) && (
<div className="-ml-1.5 flex items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{text && (
<ActionButton
label={
copied
? dict.chat.copied
: dict.chat.copyResponse
}
onClick={async () => {
const ok = await copyText(text)
if (!ok)
toast.error(dict.chat.failedToCopyDetail)
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
)}
{isLastAssistant && !isCachedExample && (
<ActionButton
label={dict.chat.regenerate}
onClick={() => engine.regenerate(index)}
>
<RotateCcw />
</ActionButton>
)}
<ActionButton
label={dict.chat.goodResponse}
active={feedback === "good"}
onClick={() => submitFeedback("good")}
>
<ThumbsUp
className={cn(
feedback === "good" && "fill-current",
)}
/>
</ActionButton>
<ActionButton
label={dict.chat.badResponse}
active={feedback === "bad"}
onClick={() => submitFeedback("bad")}
>
<ThumbsDown
className={cn(feedback === "bad" && "fill-current")}
/>
</ActionButton>
</div>
)}
</div>
)
}
/**
* A failed request: what to do in the text color, the provider's own words
* below it, and the action that can fix it first
*/
function ErrorNotice({
text,
openModelConfig,
onOpenModelConfig,
onRetry,
}: {
text: string
openModelConfig: boolean
onOpenModelConfig: () => void
onRetry?: () => void
}) {
const dict = useDictionary()
// Provider errors are "hint\n\nprovider message"
const split = text.indexOf("\n\n")
const headline = split >= 0 ? text.slice(0, split) : text
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
const button =
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
return (
<div
role="alert"
className="rounded-xl border border-destructive/25 bg-destructive/5 px-3.5 py-3 text-[13px]"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-line break-words font-medium text-foreground">
{headline}
</p>
{detail && (
<p className="mt-1 whitespace-pre-line break-words text-xs text-muted-foreground">
{detail}
</p>
)}
{(openModelConfig || onRetry) && (
<div className="mt-2.5 flex flex-wrap gap-2">
{openModelConfig && (
<button
type="button"
onClick={onOpenModelConfig}
className={cn(
button,
"bg-primary text-primary-foreground hover:opacity-90",
)}
>
{dict.errors.llm.openModelSettings}
</button>
)}
{onRetry && (
<button
type="button"
onClick={onRetry}
className={cn(
button,
"border border-border bg-card text-foreground hover:bg-accent",
)}
data-testid="retry-button"
>
<RotateCcw className="size-3.5" />
{dict.chat.retry}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}
export function MessageList() {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
const engine = useChatEngine()
const { messages } = engine
const endRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep the newest message in view; jump instantly on bulk loads
useEffect(() => {
if (!endRef.current || messages.length === 0) return
const prev = prevCountRef.current
prevCountRef.current = messages.length
if (prev === 0 || messages.length - prev > 1) {
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
return
}
if (!throttleRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
throttleRef.current = setTimeout(() => {
throttleRef.current = null
endRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 150)
}
}, [messages])
const lastAssistantIndex = messages
.map((m) => m.role)
.lastIndexOf("assistant")
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
<div className="space-y-4 px-4 pt-4 pb-2">
{messages.map((message, index) => {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
return (
<div
key={message.id}
className={cn(!isRestored && "animate-message-in")}
>
{message.role === "user" ? (
<UserMessage
message={message}
index={index}
isLast={index === lastUserIndex}
/>
) : message.role === "assistant" ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice
text={getMessageTextContent(message)}
openModelConfig={
!!(
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => openSettings("models")}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
lastUserIndex >= 0
? engine.retryLastMessage
: undefined
}
/>
)}
</div>
)
})}
{engine.status === "submitted" &&
messages[messages.length - 1]?.role === "user" && (
<div className="px-1 text-[13px]" aria-live="polite">
<Shimmer as="span">{dict.reasoning.thinking}</Shimmer>
</div>
)}
<div ref={endRef} />
</div>
)
}
-64
View File
@@ -1,64 +0,0 @@
import type { UIMessage } from "ai"
/** A run of plain text, or an attached file / URL whose text was inlined */
export interface TextSection {
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text" | "url"
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
export function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
const filePattern =
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = filePattern.exec(text)) !== null) {
const beforeText = text.slice(lastIndex, match.index).trim()
if (beforeText) sections.push({ type: "text", content: beforeText })
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const content = match[3].trim()
sections.push({
type: sectionType === "url" ? "url" : "file",
content,
filename: match[2].trim(),
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
const remainingText = text.slice(lastIndex).trim()
if (remainingText) sections.push({ type: "text", content: remainingText })
if (sections.length === 0) sections.push({ type: "text", content: text })
return sections
}
export function getMessageTextContent(message: UIMessage): string {
if (!message.parts) return ""
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
/** Only the user's typed text, without appended file content */
export function getUserOriginalText(message: UIMessage): string {
return getMessageTextContent(message)
.replace(APPENDED_FILE_SECTIONS_PATTERN, "")
.trim()
}
-292
View File
@@ -1,292 +0,0 @@
import { FileUp, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
/** Title shown for a session, with the default "New Chat" localized */
export function useSessionTitle(): string {
const dict = useDictionary()
const { currentTitle } = useChatEngine()
return !currentTitle || currentTitle === "New Chat"
? dict.workspace.untitled
: currentTitle
}
/**
* Popover listing saved diagrams: rename the current one, switch, delete,
* start a new one or open a file.
*/
export function SessionMenu({
children,
align = "start",
onOpenFile,
}: {
children: React.ReactNode
align?: "start" | "center" | "end"
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.sessionHistory
const params = useParams<{ lang: string }>()
const engine = useChatEngine()
const open = useUiStore((s) => s.sessionsOpen)
const setOpen = useUiStore((s) => s.setSessionsOpen)
const title = useSessionTitle()
const [query, setQuery] = useState("")
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState("")
const [toDelete, setToDelete] = useState<string | null>(null)
const renameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) {
setRenaming(false)
setQuery("")
}
}, [open])
useEffect(() => {
if (renaming) renameRef.current?.select()
}, [renaming])
const sessions = engine.sessions.filter((s) =>
s.title.toLowerCase().includes(query.trim().toLowerCase()),
)
const commitRename = async () => {
const next = draftTitle.trim()
setRenaming(false)
if (next && next !== title) await engine.renameSession(next)
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className="flex max-h-[min(560px,80vh)] w-[340px] flex-col overflow-hidden rounded-xl p-0 shadow-pop"
>
<div className="border-b border-border p-3">
{renaming ? (
<input
ref={renameRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
// Enter that picks an IME candidate
if (e.nativeEvent.isComposing) return
if (e.key === "Enter") commitRename()
if (e.key === "Escape") setRenaming(false)
}}
maxLength={100}
aria-label={t.rename}
className="h-8 w-full rounded-lg border border-foreground/20 bg-card px-2.5 text-[13px] font-medium outline-none"
/>
) : (
<button
type="button"
onClick={() => {
setDraftTitle(title)
setRenaming(true)
}}
className="group/rename flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left hover:bg-accent"
data-testid="rename-session"
>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{title}
</span>
<Pencil className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)}
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
engine.newChat()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
<Plus className="size-3.5" />
{dict.workspace.newDiagram}
</button>
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
onOpenFile()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<FileUp className="size-3.5" />
{dict.workspace.openFile}
</button>
</div>
</div>
{engine.sessions.length > 0 && (
<div className="px-3 pt-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.searchPlaceholder}
className="h-8 w-full rounded-lg bg-muted pr-2.5 pl-8 text-[13px] outline-none placeholder:text-faint"
/>
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-1.5 scrollbar-thin">
{engine.sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.empty}
</p>
) : sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.noResults}
</p>
) : (
sessions.map((session) => {
const isCurrent =
session.id === engine.currentSessionId
// The answer being written must stay in its
// own chat
const locked = engine.isBusy && !isCurrent
return (
// biome-ignore lint/a11y/useSemanticElements: contains the delete button
<div
key={session.id}
role="button"
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
title={locked ? t.busyHint : undefined}
onClick={() => {
if (locked) return
setOpen(false)
if (!isCurrent)
engine.selectSession(session.id)
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget)
return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
setOpen(false)
if (!isCurrent)
engine.selectSession(
session.id,
)
}
}}
className={cn(
"group/session flex cursor-pointer items-center gap-2.5 rounded-lg p-1.5 hover:bg-accent",
isCurrent && "bg-accent",
locked &&
"cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span className="sheet-light flex h-9 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
{session.thumbnailDataUrl && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={
session.thumbnailDataUrl
}
alt=""
className="max-h-8 max-w-11 object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
{session.title === "New Chat"
? dict.workspace.untitled
: session.title}
</span>
<span className="block text-[11px] text-faint">
{formatRelativeTime(
session.updatedAt,
params.lang,
t.justNow,
)}
</span>
</span>
<button
type="button"
disabled={
isCurrent && engine.isBusy
}
onClick={(e) => {
e.stopPropagation()
setToDelete(session.id)
}}
aria-label={t.deleteTitle}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-faint [@media(hover:hover)]:opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover/session:opacity-100 focus-visible:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
})
)}
</div>
</PopoverContent>
</Popover>
<AlertDialog
open={!!toDelete}
onOpenChange={(o) => !o && setToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{t.deleteDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
if (toDelete) engine.deleteSession(toDelete)
setToDelete(null)
}}
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
-490
View File
@@ -1,490 +0,0 @@
import {
AlertCircle,
Check,
ChevronDown,
Code2,
Copy,
Library,
PenTool,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react"
import { useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { CodeBlock } from "@/components/code-block"
import { useDictionary } from "@/hooks/use-dictionary"
import { copyText } from "@/lib/clipboard"
import { formatMessage } from "@/lib/i18n/utils"
import { cn, isMxCellXmlComplete } from "@/lib/utils"
import { describeChanges } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import type { DiagramOperation, ToolPartLike } from "./types"
export function ToolInputDetails({ part }: { part: ToolPartLike }) {
const { input, state } = part
if (!input || typeof input !== "object") return null
if (typeof input.xml === "string" && input.xml) {
// Plain text while streaming; highlighting re-runs on every chunk
return state === "input-streaming" || state === "input-available" ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground scrollbar-thin">
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
}
if (Array.isArray(input.operations)) {
// Streamed or invalid input can hold anything: show only what React
// can render (an object in place of a string would crash the chat)
const shown = (input.operations as DiagramOperation[]).filter(
(op) =>
typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) =>
typeof value === "string" ? value : ""
return (
<div className="space-y-2">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1 text-[11px]">
<span className="font-medium text-foreground">
{op.operation}
</span>
<span className="text-muted-foreground">
{text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<pre className="overflow-x-auto whitespace-pre-wrap break-all px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
{text(op.new_xml)}
</pre>
)}
</div>
))}
</div>
)
}
if (Object.keys(input).length === 0) return null
return <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
}
function toolText(part: ToolPartLike): string {
const { input } = part
if (!input || typeof input !== "object") return ""
if (typeof input.xml === "string" && input.xml) return input.xml
if (Array.isArray(input.operations)) {
return JSON.stringify(input.operations, null, 2)
}
return JSON.stringify(input, null, 2)
}
/** Thumbnail of a version, or what stands in for it */
function VersionThumb({
version,
dimmed,
className,
}: {
version: DiagramVersion
dimmed?: boolean
className?: string
}) {
const t = useDictionary().versions
if (version.svg) {
return (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className={cn(
"object-contain transition-opacity",
dimmed && "opacity-40",
className,
)}
/>
)
}
if (version.svg === "") {
return <span className="text-xs text-faint">{t.noPreview}</span>
}
return (
<Shimmer as="span" className="text-xs">
{t.rendering}
</Shimmer>
)
}
/**
* Card for one diagram version produced by a tool call. The latest shows
* its picture; older ones fold into one row (the picture opens Compare).
*/
function VersionCard({
part,
version,
index,
isLatest,
}: {
part: ToolPartLike
version: DiagramVersion
index: number
isLatest: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const [showCode, setShowCode] = useState(false)
const [copied, setCopied] = useState(false)
const isUndone = undoneVersionId === version.id
const isOnCanvas = onCanvasVersionId === version.id
const isEdit = part.type === "tool-edit_diagram"
// Undo/redo belongs to the newest change while it is (or was) on the
// canvas; after an older version was restored, this one is restored too
const canUndo =
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
const summary = version.fromScratch
? formatMessage(t.totals, {
shapes: version.summary.shapesAdded,
edges: version.summary.edgesAdded,
})
: describeChanges(version.summary, t)
const badge = (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
isOnCanvas
? "bg-marker-soft text-marker-ink"
: "bg-muted text-muted-foreground",
)}
>
v{index + 1}
</span>
)
const actionClass =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
const restoreButton = (
<button
type="button"
disabled={engine.isBusy || isOnCanvas}
onClick={() => engine.restoreVersion(version.id)}
className={actionClass}
data-testid="version-restore"
>
<RotateCcw className="size-3.5" />
{formatMessage(t.restoreVersion, { n: index + 1 })}
</button>
)
if (!isLatest) {
return (
<div
className="flex items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2"
data-testid="version-card"
data-tool-state={part.state}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
aria-label={t.compare}
title={t.compare}
data-testid="version-thumb-small"
>
<VersionThumb
version={version}
className="max-h-8 max-w-[52px]"
/>
</button>
{badge}
<div className="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] text-foreground">
{title}
</div>
<div className="truncate text-xs text-muted-foreground">
{summary}
</div>
</div>
{restoreButton}
</div>
)
}
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
isOnCanvas && "border-foreground/15",
)}
data-testid="version-card"
data-tool-state={part.state}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
aria-label={t.compare}
>
<VersionThumb
version={version}
dimmed={isUndone}
className="max-h-20 max-w-[88%]"
/>
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
{t.compare}
</span>
</button>
<div className="flex items-center gap-2.5 px-3 py-2.5">
{badge}
<div className="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary}
</div>
</div>
<button
type="button"
onClick={() => setShowCode((v) => !v)}
aria-label={showCode ? t.hideCode : t.showCode}
title={showCode ? t.hideCode : t.showCode}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Code2 className="size-4" />
</button>
{canUndo ? (
<button
type="button"
disabled={engine.isBusy}
onClick={() => engine.undoVersion(version.id)}
className={actionClass}
data-testid="version-undo"
>
{isUndone ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{isUndone ? t.redo : t.undo}
</button>
) : (
restoreButton
)}
</div>
{showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
<button
type="button"
onClick={async () => {
setCopied(await copyText(toolText(part)))
setTimeout(() => setCopied(false), 1500)
}}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<ToolInputDetails part={part} />
</div>
)}
</div>
)
}
/** Compact row for tool calls that are running, failed or produced no version */
function ToolRow({
part,
icon,
label,
tone = "default",
detail,
}: {
part: ToolPartLike
icon: React.ReactNode
label: React.ReactNode
tone?: "default" | "error" | "warning"
detail?: string
}) {
const [open, setOpen] = useState(false)
const hasInput = !!part.input && Object.keys(part.input).length > 0
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
tone === "error" && "border-destructive/25",
)}
data-testid="tool-row"
data-tool-name={part.type?.replace("tool-", "")}
data-tool-state={part.state}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={!hasInput && !detail}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
>
<span
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
tone === "error" &&
"bg-destructive/10 text-destructive",
tone === "warning" && "bg-marker-soft text-marker-ink",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{label}
</span>
{(hasInput || detail) && (
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
)}
</button>
{open && (
<div className="space-y-2 border-t border-border bg-surface-1 px-3 py-2.5">
{detail && (
<p
className={cn(
"whitespace-pre-wrap break-words text-xs",
tone === "error"
? "text-destructive"
: "text-muted-foreground",
)}
>
{detail}
</p>
)}
{hasInput && <ToolInputDetails part={part} />}
</div>
)}
</div>
)
}
export function ToolActivity({
part,
isLastMessage,
}: {
part: ToolPartLike
isLastMessage: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const toolName = part.type?.replace("tool-", "")
const { state, errorText, output } = part
const versionIndex = versions.findIndex(
(v) => v.toolCallId === part.toolCallId,
)
if (state === "output-available" && versionIndex >= 0) {
return (
<VersionCard
part={part}
version={versions[versionIndex]}
index={versionIndex}
isLatest={versionIndex === versions.length - 1}
/>
)
}
if (toolName === "get_shape_library" && state !== "output-error") {
const isDone = state === "output-available"
return (
<ToolRow
part={part}
icon={<Library />}
label={
isDone ? (
t.libraryLoaded
) : (
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
)
}
detail={
isDone && typeof output === "string"
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
: undefined
}
/>
)
}
if (state === "input-streaming" || state === "input-available") {
return (
<ToolRow
part={part}
icon={<PenTool />}
label={
<Shimmer as="span">
{toolName === "edit_diagram" ? t.editing : t.drawing}
</Shimmer>
}
/>
)
}
if (state === "output-error") {
const stopped = errorText === "Stopped by user"
// Incomplete XML means the output hit the length limit. Without an
// input the JSON was broken (the server repairs JSON cut short by
// the limit).
const truncated =
!stopped &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof part.input?.xml === "string" &&
!isMxCellXmlComplete(part.input.xml)
return (
<ToolRow
part={part}
icon={<AlertCircle />}
tone={stopped ? "default" : truncated ? "warning" : "error"}
label={
stopped
? t.stopped
: truncated
? dict.tools.truncated
: isLastMessage && engine.isBusy
? t.failedRetrying
: t.failed
}
detail={
stopped
? undefined
: truncated
? t.truncatedHint
: (errorText ??
(typeof output === "string" ? output : ""))
}
/>
)
}
// Finished without a version (sessions saved before versions existed)
return (
<ToolRow
part={part}
icon={<PenTool />}
label={toolName === "edit_diagram" ? t.edited : t.created}
/>
)
}
+5 -4
View File
@@ -1,6 +1,8 @@
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation }
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface ToolPartLike {
type: string
@@ -11,5 +13,4 @@ export interface ToolPartLike {
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
errorText?: string
}
-223
View File
@@ -1,223 +0,0 @@
"use client"
import type { UIMessage } from "ai"
import type { RefObject } from "react"
import { useCallback, useEffect, useRef } from "react"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { useDiagram } from "@/contexts/diagram-context"
import {
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// 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.operation === "string" &&
["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
(op.operation === "delete" || typeof op.new_xml === "string"),
)
}
interface UseDiagramStreamingParams {
messages: UIMessage[]
processedToolCallsRef: RefObject<Set<string>>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
loadedMessageIdsRef: RefObject<Set<string>>
}
/**
* Draws AI tool output onto the canvas while it streams, as a preview that
* is not recorded in undo history.
*
* display_diagram: the complete mxCells written so far. edit_diagram: the
* complete operations applied to the diagram from before the call. The tool
* handler validates and commits the final result. Runs in the chat engine
* so streaming continues while the chat panel is collapsed.
*/
export function useDiagramStreaming({
messages,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
}: UseDiagramStreamingParams) {
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
const previousXML = useRef<string>("")
// Last processed XML per toolCallId, to skip redundant work while streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset when messages become empty (new chat or session switch), so
// cached examples work again after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
processedToolCallsRef.current.clear()
editDiagramOriginalXmlRef.current.clear()
}
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
try {
// Replace the first page's cells so other pages stay intact.
// An empty canvas gets a default mxfile to put the cells in.
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
loadDiagram(replacedXML, true, "preview")
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, loadDiagram],
)
useEffect(() => {
// Only the last message can still be streaming
const message = messages[messages.length - 1]
if (!message?.parts) return
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
if (loadedMessageIdsRef.current.has(message.id)) return
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
for (const part of message.parts) {
if (
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
continue
}
const { toolCallId, state, input } = part as ToolPartLike
// Failed or stopped: if the original XML is still stored, the
// tool handler never ran (invalid JSON, or the user pressed
// stop), so undo the streamed preview here. Invalid JSON leaves
// no input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(toolCallId)
loadDiagram(originalXml, true, "revert")
baseXml = originalXml
}
continue
}
// Input complete, or the tool handler, a stop or an error took
// the call already: the tool handler loads the checked diagram.
// The messages update at most every 150 ms (useChat throttle in
// the chat engine), so they can still show the call streaming
// after that.
if (
state !== "input-streaming" ||
processedToolCallsRef.current.has(toolCallId)
) {
processedToolCallsRef.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(`${toolCallId}-opCount`)
continue
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
continue
// Keep the diagram from before the preview, to undo it on a
// stop or an error
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
continue
}
// edit_diagram: apply operations incrementally for a preview.
// editDiagramOriginalXmlRef is shared with the tool handler.
if (!input?.operations) continue
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) continue
// Capture the original XML when streaming starts
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
continue
}
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (!originalXml) continue
// Skip if no change from last processed state
const countKey = `${toolCallId}-opCount`
const opCount = String(completeOps.length)
if (lastProcessedXmlRef.current.get(countKey) === opCount) continue
try {
const { result } = applyDiagramOperations(
originalXml,
completeOps,
)
// Load the full document so other pages stay intact
loadDiagram(result, true, "preview")
lastProcessedXmlRef.current.set(countKey, opCount)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
)
}
}
}, [
messages,
handleDisplayChart,
chartXMLRef,
loadDiagram,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
])
}
-154
View File
@@ -1,154 +0,0 @@
import type { RefObject } from "react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import type { DiagramCommit, LoadMode } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { countCells, diffDiagrams } from "@/lib/diagram-diff"
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
import { formatMessage } from "@/lib/i18n/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import { useVersionsStore } from "@/stores/versions-store"
interface UseVersionsParams {
loadDiagram: (
xml: string,
skipValidation?: boolean,
mode?: LoadMode,
) => string | null
getVersionSvg: () => Promise<string | null>
setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** Index of the user message whose turn is running */
currentTurnRef: RefObject<number>
/** The document on the canvas now */
chartXMLRef: RefObject<string>
isBusy: boolean
/** Live busy state, for the restore toast's Undo that runs later */
isTurnActive: () => boolean
/** Changes when another chat comes on screen */
getChatGeneration: () => number
}
/**
* Turns every committed AI change into a version (card in the chat), marks
* the changed shapes on the canvas, and restores or undoes versions.
*/
export function useVersions({
loadDiagram,
getVersionSvg,
setCommitHandler,
currentTurnRef,
chartXMLRef,
isBusy,
isTurnActive,
getChatGeneration,
}: UseVersionsParams) {
const dict = useDictionary()
// A restore commits too; that path must not add a version
const restoringRef = useRef(false)
// Registered once: re-registering on every render would leave a gap
// (effect cleanups run before the streaming hook commits). The handler
// only reads refs and stable functions.
useEffect(() => {
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
if (restoringRef.current) return
const { summary, touchedIds } = diffDiagrams(beforeXml, afterXml)
const before = countCells(beforeXml)
const fromScratch = before.shapes + before.edges === 0
const id = useVersionsStore.getState().addVersion({
xml: afterXml,
beforeXml,
turnIndex: currentTurnRef.current,
toolCallId,
summary,
fromScratch,
})
// Mark what changed, unless the whole diagram is new
if (!fromScratch) {
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(touchedIds, marker || "#ffd84d")
}, 60)
}
// The picture is of the canvas: take it right away, before a
// newer version can be on it; once one is, this one gets none
// rather than the newer one's
const isNewest = () =>
useVersionsStore.getState().versions.at(-1)?.id === id
setTimeout(async () => {
const svg = isNewest() ? await getVersionSvg() : null
useVersionsStore
.getState()
.updateVersion(id, { svg: svg && isNewest() ? svg : "" })
}, 0)
})
return () => setCommitHandler(null)
}, [])
const apply = (xml: string) => {
restoringRef.current = true
try {
loadDiagram(xml, true, "commit")
} finally {
restoringRef.current = false
}
clearHighlights()
}
const restoreVersion = (versionId: string) => {
if (isBusy) return
const store = useVersionsStore.getState()
const index = store.versions.findIndex((v) => v.id === versionId)
if (index < 0) return
// The canvas before the restore, to go back to it: a multi-page
// document is loaded in full, which Ctrl+Z cannot undo
const before = chartXMLRef.current
const wasOnCanvas = store.onCanvasVersionId
const wasUndone = store.undoneVersionId
const generation = getChatGeneration()
apply(store.versions[index].xml)
store.setOnCanvas(versionId)
toast.success(
formatMessage(dict.versions.restoredVersion, { n: index + 1 }),
{
id: "version-restored",
duration: 8000,
action: {
label: dict.versions.undoRestore,
onClick: () => {
// Another chat is on screen, or an answer is
// changing the canvas: the old canvas is not theirs
if (!before || isTurnActive()) return
if (getChatGeneration() !== generation) return
apply(before)
const now = useVersionsStore.getState()
if (wasUndone) now.setUndone(wasUndone)
else if (wasOnCanvas) now.setOnCanvas(wasOnCanvas)
},
},
},
)
}
/** Undo the change of the latest version, or redo it when undone */
const undoVersion = (versionId: string) => {
if (isBusy) return
const store = useVersionsStore.getState()
const version = store.versions.find((v) => v.id === versionId)
if (!version) return
if (store.undoneVersionId === versionId) {
apply(version.xml)
store.setOnCanvas(versionId)
return
}
if (version.beforeXml === undefined) return
apply(version.beforeXml || BLANK_MXFILE)
store.setUndone(versionId)
}
return { restoreVersion, undoVersion }
}
-88
View File
@@ -1,88 +0,0 @@
import { useEffect, useRef } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
/** Row of version thumbnails under the panel header; click to compare */
export function VersionStrip() {
const dict = useDictionary()
const versions = useVersionsStore((s) => s.versions)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const engine = useChatEngine()
const scrollRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
}, [versions.length])
// Versions with no card in the chat: saved by older app versions, or
// from a turn that was retried
const hasCardless = versions.some(
(v) =>
!v.toolCallId ||
!engine.messages.some((m) =>
m.parts?.some(
(p) =>
(p as { toolCallId?: string }).toolCallId ===
v.toolCallId,
),
),
)
// One version with a card: the strip adds nothing to it
if (versions.length < 2 && !hasCardless) return null
return (
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
<span className="shrink-0 text-xs text-muted-foreground">
{dict.versions.strip}
</span>
<div
ref={scrollRef}
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
>
{versions.map((version, index) => {
const isCurrent = version.id === onCanvasVersionId
return (
<button
key={version.id}
type="button"
onClick={() => openCompare(version.id)}
title={`v${index + 1}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
className={cn(
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
// An outline with a gap reads on the white
// picture in both themes
isCurrent &&
"outline-2 outline-offset-2 outline-foreground",
)}
data-testid="version-thumb"
>
{version.svg && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className="max-h-9 max-w-[62px] object-contain"
/>
)}
<span
className={cn(
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
isCurrent &&
"font-semibold text-neutral-900",
)}
>
v{index + 1}
</span>
</button>
)
})}
</div>
</div>
)
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { wrapWithMxFile } from "@/lib/utils"
// Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = {
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
})
// Display the final diagram
const prepared = prepareNewDiagram(xml)
if (prepared.ok) onDisplayChart(prepared.xml)
const fullXml = wrapWithMxFile(xml)
onDisplayChart(fullXml)
setIsSimulating(false)
}
+1 -1
View File
@@ -1,8 +1,8 @@
"use client"
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
+114
View File
@@ -0,0 +1,114 @@
"use client"
import Image from "next/image"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
onToggleHistory: (show: boolean) => void
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
// Skip validation for trusted history snapshots
onDisplayChart(diagramHistory[selectedIndex].xml, true)
handleClose()
}
}
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
{diagramHistory.map((item, index) => (
<div
key={index}
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
selectedIndex === index
? "border-primary ring-2 ring-primary"
: ""
}`}
onClick={() => setSelectedIndex(index)}
>
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
{dict.history.version} {index + 1}
</div>
</div>
))}
</div>
)}
<DialogFooter>
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
{dict.common.close}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
-16
View File
@@ -1,16 +0,0 @@
import NextImage, { type ImageProps } from "next/image"
import { forwardRef } from "react"
import { getAssetUrl } from "@/lib/base-path"
export default forwardRef<HTMLImageElement, ImageProps>(
function Image(props, ref) {
const src =
typeof props.src === "string" &&
props.src.startsWith("/") &&
!props.src.startsWith("//")
? getAssetUrl(props.src)
: props.src
return <NextImage {...props} src={src} ref={ref} />
},
)
File diff suppressed because it is too large Load Diff
+213 -269
View File
@@ -26,23 +26,37 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ProviderLogo } from "@/components/provider-logo"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure?: () => void
onConfigure: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
}
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
@@ -67,22 +81,6 @@ function groupModelsByProvider(
return groups
}
/**
* The model id without the provider or region prefix that the logo already
* shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5"
* become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the
* tooltip and the list.
*/
export function shortModelName(id: string): string {
const name = id.slice(id.lastIndexOf("/") + 1)
return (
name.replace(
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
"",
) || name
)
}
export function ModelSelector({
models,
selectedModelId,
@@ -128,7 +126,9 @@ export function ModelSelector({
)
const handleSelect = (value: string) => {
if (value === "__server_default__") {
if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
onSelect(undefined)
} else {
onSelect(value)
@@ -175,7 +175,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="min-w-0 max-w-44">
<div ref={wrapperRef} className="inline-block">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -184,26 +184,18 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
data-testid="model-selector"
>
{selectedModel ? (
<ProviderLogo
provider={selectedModel.provider}
className="size-3.5 flex-shrink-0 opacity-80"
/>
) : (
<Bot className="h-4 w-4 flex-shrink-0" />
)}
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
<span className="text-xs truncate">
{selectedModel
? shortModelName(selectedModel.modelId)
? selectedModel.modelId
: dict.modelConfig.default}
</span>
) : (
@@ -222,247 +214,199 @@ export function ModelSelector({
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
<ModelSelectorEmpty>
{displayModels.length === 0 &&
models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* Server Models Section */}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict
.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict
.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(
groupedUserModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict
.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
<div className="relative z-10 shrink-0 border-t bg-background">
{onConfigure && (
<div className="px-3 py-2">
<ModelSelectorItem
value="__configure_models__"
onSelect={() => {
onConfigure()
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</div>
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</div>
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>
-298
View File
@@ -1,298 +0,0 @@
"use client"
import { Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
// renderSecret. That (and the optional test action) are the only genuine
// differences between the two screens; the field structure is shared here.
export type SecretField =
| "apiKey"
| "awsAccessKeyId"
| "awsSecretAccessKey"
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
export const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
["eu-west-1", "Ireland"],
["eu-west-2", "London"],
["eu-west-3", "Paris"],
["eu-central-1", "Frankfurt"],
["ap-south-1", "Mumbai"],
["ap-northeast-1", "Tokyo"],
["ap-northeast-2", "Seoul"],
["ap-southeast-1", "Singapore"],
["ap-southeast-2", "Sydney"],
["sa-east-1", "São Paulo"],
]
interface ProviderCredentialsFieldsProps {
provider: ProviderName
// Plain (non-secret) field values — secrets are owned by renderSecret
name?: string
baseUrl?: string
awsRegion?: string
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
// Render the control for a secret field. The caller may include trailing
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
}
// Display name + per-provider credential inputs, shared by the user
// ModelConfigDialog and the admin Models panel.
export function ProviderCredentialsFields({
provider,
name,
baseUrl,
awsRegion,
disabled,
onChange,
renderSecret,
footer,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{provider === "bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : provider === "vertexai" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</>
) : (
<>
{/* API Key, with a link to where the provider issues keys */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
</>
)}
{footer}
</div>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
// that have no logo there
export function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt=""
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
+27 -49
View File
@@ -23,22 +23,9 @@ export function QuotaLimitToast({
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit
? isSelfHosted
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -84,24 +71,19 @@ export function QuotaLimitToast({
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>{quotaMessage}</p>
{!isSelfHosted && (
<p
dangerouslySetInnerHTML={{
__html: formatMessage(
dict.quota.doubaoSponsorship,
{
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
},
),
}}
/>
)}
<p>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p
dangerouslySetInnerHTML={{
__html: tipHtml,
__html: formatMessage(dict.quota.doubaoSponsorship, {
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
}),
}}
/>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
@@ -119,28 +101,24 @@ export function QuotaLimitToast({
{dict.quota.configModel}
</button>
)}
{!isSelfHosted && (
<>
<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 border border-border text-foreground hover:bg-muted transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
</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" />
{dict.quota.sponsor}
</a>
</>
)}
<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 border border-border text-foreground hover:bg-muted transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
</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" />
{dict.quota.sponsor}
</a>
</div>
</div>
)
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onClear: () => void
}
export function ResetWarningModal({
open,
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+38 -52
View File
@@ -10,10 +10,17 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
export type ExportFormat = "drawio" | "png" | "svg"
interface SaveDialogProps {
open: boolean
@@ -55,34 +62,25 @@ export function SaveDialog({
{
value: "drawio" as const,
label: dict.save.formats.drawio,
hint: dict.save.hints.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
hint: dict.save.hints.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
hint: dict.save.hints.svg,
extension: ".svg",
},
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
hint: dict.save.hints.xmlsvg,
extension: ".drawio.svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
@@ -90,54 +88,44 @@ export function SaveDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div
role="radiogroup"
aria-label={dict.save.format}
className="grid grid-cols-2 gap-2"
>
{FORMAT_OPTIONS.map((opt) => (
// biome-ignore lint/a11y/useSemanticElements: styled radio tile
<button
key={opt.value}
type="button"
role="radio"
aria-checked={format === opt.value}
onClick={() => setFormat(opt.value)}
className={cn(
"rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/30",
format === opt.value &&
"border-foreground bg-accent",
)}
>
<span className="block text-[13px] font-medium text-foreground">
{opt.label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{opt.hint}
</span>
</button>
))}
<div className="space-y-2">
<label className="text-sm font-medium">
{dict.save.format}
</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="export-filename"
className="text-sm font-medium"
>
<label className="text-sm font-medium">
{dict.save.filename}
</label>
{/* One field: the name, then its extension */}
<div className="flex h-9 items-center overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring/40">
<input
id="export-filename"
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={dict.save.filenamePlaceholder}
autoFocus
onFocus={(e) => e.target.select()}
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
className="rounded-r-none border-r-0 focus-visible:z-10"
/>
<span className="shrink-0 pr-3 text-sm text-muted-foreground">
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
{currentFormat?.extension || ".drawio"}
</span>
</div>
@@ -150,9 +138,7 @@ export function SaveDialog({
>
{dict.common.cancel}
</Button>
<Button onClick={handleSave}>
{dict.save.exportButton}
</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+577
View File
@@ -0,0 +1,577 @@
"use client"
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
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"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout
function SettingItem({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div className="space-y-0.5 pr-4">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground max-w-[260px]">
{description}
</p>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
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"
}
function SettingsContent({
open,
onOpenChange,
drawioUi,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/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)
})
}, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => {
if (open) {
const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
const storedSendShortcut = localStorage.getItem(
STORAGE_KEYS.sendShortcut,
)
setSendShortcut(storedSendShortcut || "ctrl-enter")
setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
}
}, [open])
const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
// Notify Electron main process to update its menu language
if (window.electronAPI?.setUserLocale) {
window.electronAPI.setUserLocale(lang).catch((error) => {
console.error("Failed to sync locale with Electron:", error)
})
}
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => {
if (!accessCodeRequired) return
setError("")
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
},
)
const data = await response.json()
if (!data.valid) {
setError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
} catch {
setError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleSave()
}
}
const handleApplyProxy = async () => {
if (!window.electronAPI?.setProxy) return
// Validate proxy URLs (must start with http:// or https://)
const validateProxyUrl = (url: string): boolean => {
if (!url) return true // Empty is OK
return url.startsWith("http://") || url.startsWith("https://")
}
const trimmedHttp = httpProxy.trim()
const trimmedHttps = httpsProxy.trim()
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
toast.error("HTTP Proxy must start with http:// or https://")
return
}
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
toast.error("HTTPS Proxy must start with http:// or https://")
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: trimmedHttp || undefined,
httpsProxy: trimmedHttps || undefined,
})
if (result.success) {
toast.success(dict.settings.proxyApplied)
} else {
toast.error(result.error || "Failed to apply proxy settings")
}
} catch {
toast.error("Failed to apply proxy settings")
} finally {
setIsApplyingProxy(false)
}
}
return (
<DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription className="mt-1">
{dict.settings.description}
</DialogDescription>
</DialogHeader>
{/* Content */}
<div className="px-6 pb-6">
<div className="divide-y divide-border-subtle">
{/* Access Code (conditional) */}
{accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="access-code"
className="text-sm font-medium"
>
{dict.settings.accessCode}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
</div>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
className="h-9"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
className="h-9 px-4 rounded-xl"
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
)}
{/* Language */}
<SettingItem
label={dict.settings.language}
description={dict.settings.languageDescription}
>
<Select
value={currentLang}
onValueChange={changeLanguage}
>
<SelectTrigger
id="language-select"
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingItem>
{/* Theme */}
<SettingItem
label={dict.settings.theme}
description={dict.settings.themeDescription}
>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</SettingItem>
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
>
<Button
id="drawio-ui"
variant="outline"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</SettingItem>
{/* Diagram Style */}
<SettingItem
label={dict.settings.diagramStyle}
description={dict.settings.diagramStyleDescription}
>
<div className="flex items-center gap-2">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
/>
<span className="text-sm text-muted-foreground">
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</span>
</div>
</SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
description={dict.settings.sendShortcutDescription}
>
<Select
value={sendShortcut}
onValueChange={(value) => {
setSendShortcut(value)
localStorage.setItem(
STORAGE_KEYS.sendShortcut,
value,
)
window.dispatchEvent(
new CustomEvent("sendShortcutChange", {
detail: value,
}),
)
}}
>
<SelectTrigger
id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{dict.settings.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{dict.settings.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Proxy Settings - Electron only */}
{typeof window !== "undefined" &&
window.electronAPI?.isElectron && (
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{dict.settings.proxy}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.proxyDescription}
</p>
</div>
<div className="space-y-2">
<Input
id="http-proxy"
type="text"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
type="text"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
</div>
<Button
onClick={handleApplyProxy}
disabled={isApplyingProxy}
className="h-9 px-4 rounded-xl w-full"
>
{isApplyingProxy
? "..."
: dict.settings.applyProxy}
</Button>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<div className="flex items-center justify-center gap-3">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="h-3 w-3" />
{process.env.APP_VERSION}
</span>
<span className="text-muted-foreground">·</span>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Github className="h-3 w-3" />
GitHub
</a>
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<>
<span className="text-muted-foreground">·</span>
<a
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
{dict.nav.about}
</a>
</>
)}
</div>
</div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-lg p-0">
<div className="h-80 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
</Dialog>
)
}
-584
View File
@@ -1,584 +0,0 @@
import {
Github,
Info,
KeyRound,
Monitor,
Moon,
Palette,
SlidersHorizontal,
Sun,
Terminal,
} from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { useChatEngine } from "@/components/chat/chat-engine"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { BrandMark } from "@/components/workspace/brand-mark"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn } from "@/lib/utils"
import {
type SendShortcut,
type ThemePreference,
useSettingsStore,
} from "@/stores/settings-store"
import { type SettingsTab, useUiStore } from "@/stores/ui-store"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
"zh-Hant": "繁體中文",
}
function Row({
label,
description,
htmlFor,
children,
stacked = false,
}: {
label: string
description?: string
htmlFor?: string
children: React.ReactNode
stacked?: boolean
}) {
return (
<div
className={cn(
"py-4 first:pt-1",
stacked
? "space-y-3"
: "flex items-center justify-between gap-6",
)}
>
<div className="min-w-0 space-y-0.5">
<label
htmlFor={htmlFor}
className="block text-[13px] font-medium text-foreground"
>
{label}
</label>
{description && (
<p className="max-w-[34em] text-xs text-muted-foreground">
{description}
</p>
)}
</div>
<div className={cn(!stacked && "shrink-0")}>{children}</div>
</div>
)
}
function Segmented<T extends string>({
value,
options,
onChange,
label,
}: {
value: T
options: { value: T; label: string; icon?: React.ReactNode }[]
onChange: (value: T) => void
label: string
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="inline-flex rounded-lg bg-muted p-0.5"
>
{options.map((option) => (
// biome-ignore lint/a11y/useSemanticElements: styled segmented control
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors [&_svg]:size-3.5",
value === option.value &&
"bg-card font-medium text-foreground shadow-float",
)}
>
{option.icon}
{option.label}
</button>
))}
</div>
)
}
function AppearanceTab() {
const dict = useDictionary()
const t = dict.settings
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const theme = useSettingsStore((s) => s.theme)
const setTheme = useSettingsStore((s) => s.setTheme)
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
const currentLang =
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
const changeLanguage = (lang: string) => {
localStorage.setItem(STORAGE_KEYS.locale, lang)
// The page reloads in the new language; close settings as before
useUiStore.getState().setSettingsOpen(false)
// Keep the desktop app's menu language in sync
window.electronAPI?.setUserLocale?.(lang).catch((error: unknown) => {
console.error("Failed to sync locale with Electron:", error)
})
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const query = search?.toString() ? `?${search.toString()}` : ""
router.push((parts.join("/") || "/") + query)
}
return (
<div className="divide-y divide-border">
<Row label={t.theme} description={t.themeDescription}>
<Segmented<ThemePreference>
label={t.theme}
value={theme}
onChange={setTheme}
options={[
{ value: "light", label: t.themeLight, icon: <Sun /> },
{
value: "dark",
label: t.themeDarkMode,
icon: <Moon />,
},
{
value: "system",
label: t.themeSystem,
icon: <Monitor />,
},
]}
/>
</Row>
<Row label={t.language} description={t.languageDescription}>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger
id="language-select"
className="h-8 w-[132px] rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
<Segmented<"styled" | "minimal">
label={t.diagramStyle}
value={minimalStyle ? "minimal" : "styled"}
onChange={(value) => setMinimalStyle(value === "minimal")}
options={[
{ value: "styled", label: dict.chat.styledMode },
{ value: "minimal", label: dict.chat.minimalStyle },
]}
/>
</Row>
</div>
)
}
function AdvancedTab({ open }: { open: boolean }) {
const dict = useDictionary()
const t = dict.settings
const settings = useSettingsStore()
const [accessCode, setAccessCode] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
)
const [isVerifying, setIsVerifying] = useState(false)
const [accessError, setAccessError] = useState("")
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
const isElectron =
typeof window !== "undefined" && !!window.electronAPI?.isElectron
useEffect(() => {
if (!open) return
setAccessCode(localStorage.getItem(STORAGE_KEYS.accessCode) || "")
setAccessError("")
// Re-check on every open: a stale cached value would hide the field
fetch(getApiEndpoint("/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_KEYS.accessCodeRequired,
String(required),
)
setAccessCodeRequired(required)
})
.catch(() => {})
window.electronAPI?.getProxy?.().then((config: any) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}, [open])
const saveAccessCode = async () => {
setAccessError("")
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: { "x-access-code": accessCode.trim() },
},
)
const data = await response.json()
if (!data.valid) {
setAccessError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_KEYS.accessCode, accessCode.trim())
toast.success(t.accessCodeSaved)
} catch {
setAccessError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
}
const applyProxy = async () => {
if (!window.electronAPI?.setProxy) return
const isValid = (url: string) =>
!url || url.startsWith("http://") || url.startsWith("https://")
const http = httpProxy.trim()
const https = httpsProxy.trim()
if (!isValid(http) || !isValid(https)) {
toast.error(t.proxyInvalid)
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: http || undefined,
httpsProxy: https || undefined,
})
if (result.success) toast.success(t.proxyApplied)
else toast.error(result.error || t.proxyFailed)
} catch {
toast.error(t.proxyFailed)
} finally {
setIsApplyingProxy(false)
}
}
return (
<div className="divide-y divide-border">
{accessCodeRequired && (
<Row
label={t.accessCode}
description={t.accessCodeDescription}
htmlFor="access-code"
stacked
>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
saveAccessCode()
}
}}
placeholder={t.accessCodePlaceholder}
autoComplete="off"
className="h-9"
/>
<Button
onClick={saveAccessCode}
disabled={isVerifying || !accessCode.trim()}
className="h-9 rounded-lg px-4"
>
{isVerifying ? "…" : dict.common.save}
</Button>
</div>
{accessError && (
<p className="text-xs text-destructive">
{accessError}
</p>
)}
</Row>
)}
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
<Select
value={settings.sendShortcut}
onValueChange={(value) =>
settings.setSendShortcut(value as SendShortcut)
}
>
<SelectTrigger
id="send-shortcut-select"
className="h-8 w-auto rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">{t.enterToSend}</SelectItem>
<SelectItem value="ctrl-enter">
{t.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</Row>
<Row
label={t.diagramValidation}
description={t.diagramValidationDescription}
htmlFor="vlm-validation"
>
<Switch
id="vlm-validation"
checked={settings.vlmValidationEnabled}
onCheckedChange={settings.setVlmValidationEnabled}
/>
</Row>
<Row
label={t.maxOutputTokens}
description={t.maxOutputTokensDescription}
htmlFor="max-output-tokens"
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={settings.maxOutputTokens}
onChange={(e) =>
settings.setMaxOutputTokens(e.target.value)
}
placeholder="64000"
className="h-8 w-28 text-sm"
/>
</Row>
<Row
label={t.customSystemMessage}
description={t.customSystemMessageDescription}
htmlFor="custom-system-message"
stacked
>
<Textarea
id="custom-system-message"
value={settings.customSystemMessage}
onChange={(e) =>
settings.setCustomSystemMessage(e.target.value)
}
placeholder={t.customSystemMessagePlaceholder}
className="max-h-[180px] min-h-[96px] text-sm"
maxLength={5000}
/>
</Row>
{isElectron && (
<Row label={t.proxy} description={t.proxyDescription} stacked>
<div className="space-y-2">
<Input
id="http-proxy"
value={httpProxy}
onChange={(e) => setHttpProxy(e.target.value)}
placeholder={`${t.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
value={httpsProxy}
onChange={(e) => setHttpsProxy(e.target.value)}
placeholder={`${t.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
<Button
onClick={applyProxy}
disabled={isApplyingProxy}
className="h-9 w-full rounded-lg"
>
{isApplyingProxy ? "…" : t.applyProxy}
</Button>
</div>
</Row>
)}
</div>
)
}
function AboutTab() {
const dict = useDictionary()
const pathname = usePathname() || "/"
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
return (
<div className="space-y-5 pt-1">
<div className="flex items-center gap-3">
<BrandMark className="size-10 rounded-xl" />
<div>
<div className="text-[15px] font-semibold">
Next AI Draw.io
</div>
<div className="text-xs text-muted-foreground">
{dict.settings.appVersion} {process.env.APP_VERSION}
</div>
</div>
</div>
{/* MCP server card, hidden on self-hosted deployments */}
{!isSelfHosted && (
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl border border-border p-3.5 transition-colors hover:border-foreground/25"
>
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Terminal className="size-4 text-muted-foreground" />
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium">
{dict.examples.mcpServer}
</span>
<span className="block text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</span>
</span>
</a>
)}
<div className="flex flex-wrap gap-2">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
>
<Github className="size-3.5" />
GitHub
</a>
{showAbout && (
<a
href={aboutPath}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
{dict.settings.projectWebsite}
</a>
)}
</div>
</div>
)
}
export function SettingsDialog() {
const dict = useDictionary()
const t = dict.settings
const engine = useChatEngine()
const open = useUiStore((s) => s.settingsOpen)
const setOpen = useUiStore((s) => s.setSettingsOpen)
const tab = useUiStore((s) => s.settingsTab)
const setTab = useUiStore((s) => s.setSettingsTab)
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
{ id: "about", label: t.tabAbout, icon: <Info /> },
]
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
{/* On top below 1024 px: beside the model settings' own
provider list it would leave too little width */}
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
{t.title}
</DialogTitle>
{tabs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
tab === item.id &&
"bg-card font-medium text-foreground shadow-float",
)}
data-testid={`settings-tab-${item.id}`}
>
{item.icon}
{item.label}
</button>
))}
</nav>
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
<DialogDescription className="sr-only">
{t.description}
</DialogDescription>
{tab === "models" ? (
<div className="flex min-h-0 flex-1 flex-col">
<ModelConfigDialog
embedded
open={open}
onOpenChange={() => {}}
modelConfig={engine.modelConfig}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
<h2 className="mb-2 text-[15px] font-semibold">
{current.label}
</h2>
{tab === "appearance" && <AppearanceTab />}
{tab === "advanced" && <AdvancedTab open={open} />}
{tab === "about" && <AboutTab />}
</div>
)}
</section>
</DialogContent>
</Dialog>
)
}
+5 -7
View File
@@ -77,13 +77,12 @@ function CommandInput({
)
}
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => {
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
{...props}
/>
)
})
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
}
function CommandEmpty({
...props
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
-28
View File
@@ -1,28 +0,0 @@
"use client"
import { Toaster } from "sonner"
import { useSettingsStore } from "@/stores/settings-store"
/** The single toast container, mounted once at the root */
export function AppToaster() {
const isDark = useSettingsStore((s) => s.isDark)
return (
<Toaster
position="bottom-center"
// Show stacked toasts in full, so a later one never covers the
// button of an earlier one (e.g. "Continue without saving")
expand
theme={isDark ? "dark" : "light"}
offset={{ bottom: 20 }}
mobileOffset={{ bottom: 72 }}
toastOptions={{
duration: 2500,
style: { maxWidth: "480px" },
classNames: {
toast: "!rounded-xl !border-border !bg-card !text-foreground !shadow-pop",
description: "!text-muted-foreground",
},
}}
/>
)
}
-29
View File
@@ -1,29 +0,0 @@
"use client"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
/** The app icon in a rounded tile; the white variant is used in dark mode */
export function BrandMark({ className }: { className?: string }) {
return (
<span
className={cn(
"relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-card",
className,
)}
>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-192x192.png")}
alt=""
className="size-full object-contain dark:hidden"
/>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-white.svg")}
alt=""
className="hidden size-5 object-contain dark:block"
/>
</span>
)
}
-80
View File
@@ -1,80 +0,0 @@
"use client"
import type React from "react"
import { forwardRef } from "react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface IconButtonProps extends React.ComponentProps<"button"> {
label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string
active?: boolean
tooltipSide?: "top" | "bottom" | "left" | "right"
size?: "sm" | "md"
}
/** Square icon button with a tooltip; the tooltip text is also its label */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton(
{
label,
shortcut,
active = false,
tooltipSide = "bottom",
size = "md",
className,
children,
...props
},
ref,
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
aria-label={label}
aria-pressed={active || undefined}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
"disabled:pointer-events-none disabled:opacity-35",
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
size === "md" ? "size-8" : "size-7",
// A tinted tile: solid ink is for the main action
active &&
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
className,
)}
{...props}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent
side={tooltipSide}
className="flex items-center gap-2"
>
{label}
{shortcut && (
<kbd className="font-sans text-[11px] opacity-60">
{shortcut}
</kbd>
)}
</TooltipContent>
</Tooltip>
)
},
)
/** Thin vertical divider between toolbar groups */
export function ToolbarDivider() {
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
}
-409
View File
@@ -1,409 +0,0 @@
"use client"
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
CanvasStage,
DIAGRAM_FILE_ACCEPT,
} from "@/components/canvas/canvas-stage"
import {
ChatEngineProvider,
useChatEngine,
} from "@/components/chat/chat-engine"
import { ChatPanel } from "@/components/chat/chat-panel"
import { CompareDialog } from "@/components/chat/compare-dialog"
import { LobbyHero } from "@/components/chat/lobby-hero"
import { SaveDialog } from "@/components/save-dialog"
import { SettingsDialog } from "@/components/settings/settings-dialog"
import { TooltipProvider } from "@/components/ui/tooltip"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import {
debugCellIds,
debugView,
setAppShortcutHandler,
toggleShapesPanel,
} from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { modKey } from "@/lib/platform"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn, isRealDiagram } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
const MOBILE_BREAKPOINT = 768
// Gap between the floating panel and the window edge
const PANEL_GAP = 12
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const query = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
)
const update = () => setIsMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return isMobile
}
/** Restore the language picked last time (the URL may say otherwise) */
function useSavedLocale() {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (!saved || !i18n.locales.includes(saved as Locale)) return
const parts = pathname.split("/").filter(Boolean)
if (parts[0] === saved) return
parts[0] = saved
const { search, hash } = window.location
router.replace(`/${parts.join("/")}${search}${hash}`)
}, [])
}
// Stores on window in development, for debugging and end-to-end tests
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
;(window as any).__naiStores = {
canvas: useCanvasStore,
versions: useVersionsStore,
ui: useUiStore,
settings: useSettingsStore,
cellIds: debugCellIds,
view: debugView,
}
}
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
export function exportFilename(title: string | null): string {
const name = (title && title !== "New Chat" ? title : "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
// Local date (toISOString would give UTC)
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
}
/** The whole editor: canvas, chat panel, dialogs */
export function Workspace() {
const hydrate = useSettingsStore((s) => s.hydrate)
useEffect(() => {
hydrate()
}, [hydrate])
useSavedLocale()
return (
<TooltipProvider delayDuration={350}>
<ChatEngineProvider>
<WorkspaceLayout />
</ChatEngineProvider>
</TooltipProvider>
)
}
function WorkspaceLayout() {
const dict = useDictionary()
const engine = useChatEngine()
const { chartXML, saveDiagramToFile } = useDiagram()
const isMobile = useIsMobile()
const panelWidth = useSettingsStore((s) => s.panelWidth)
const {
panelOpen,
togglePanel,
setPanelOpen,
mobileView,
setMobileView,
openSettings,
focusComposer,
saveDialogOpen,
setSaveDialogOpen,
} = useUiStore()
const [heroDismissed, setHeroDismissed] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Start screen: nothing said and nothing drawn yet
const showHero =
engine.isRestored &&
engine.messages.length === 0 &&
!isRealDiagram(chartXML) &&
!heroDismissed
// A new conversation brings the start screen back (a new chat gets a
// new tracing id, also when it was empty before)
const chatId = engine.langfuseSessionId
useEffect(() => {
setHeroDismissed(false)
}, [chatId])
const messageCount = engine.messages.length
const wasEmptyRef = useRef(true)
useEffect(() => {
if (messageCount > 0) wasEmptyRef.current = false
else if (!wasEmptyRef.current) {
wasEmptyRef.current = true
setHeroDismissed(false)
}
}, [messageCount])
// Phones: show the canvas while the AI draws and when a new version
// lands, so the result is visible; the chat tab gets a dot meanwhile
const isBusy = engine.isBusy
const versionCount = useVersionsStore((s) => s.versions.length)
const [chatUnread, setChatUnread] = useState(false)
const lastVersionCountRef = useRef(versionCount)
useEffect(() => {
if (isMobile && isBusy) setMobileView("canvas")
}, [isMobile, isBusy, setMobileView])
useEffect(() => {
const grew = versionCount > lastVersionCountRef.current
lastVersionCountRef.current = versionCount
if (isMobile && grew && engine.isRestored) {
setMobileView("canvas")
setChatUnread(true)
}
}, [versionCount, isMobile, engine.isRestored, setMobileView])
useEffect(() => {
if (mobileView === "chat") setChatUnread(false)
}, [mobileView])
// ⌘B toggles the panel; ⌘/ asks the AI about the selection; ⌘⇧K
// shows the shape library
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return false
if (event.shiftKey && (event.key === "k" || event.key === "K")) {
toggleShapesPanel()
return true
}
if (event.key === "b" || event.key === "B") {
if (isMobile) return false
togglePanel()
return true
}
if (event.key === "/") {
setHeroDismissed(true)
focusComposer()
return true
}
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (handle(event)) event.preventDefault()
}
// draw.io takes focus when it (re)loads, e.g. after a language
// switch; Escape typed there must still close our dialogs.
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
const handleInFrame = (event: KeyboardEvent) => {
if (event.key === "b" || event.key === "B") return false
if (event.key === "Escape") {
const ui = useUiStore.getState()
if (
ui.settingsOpen ||
ui.compareVersionId ||
ui.saveDialogOpen
) {
ui.setSettingsOpen(false)
ui.closeCompare()
ui.setSaveDialogOpen(false)
return true
}
}
return handle(event)
}
window.addEventListener("keydown", onKeyDown)
setAppShortcutHandler(handleInFrame)
return () => {
window.removeEventListener("keydown", onKeyDown)
setAppShortcutHandler(null)
}
}, [isMobile, togglePanel, focusComposer])
const openFile = () => fileInputRef.current?.click()
const dismissHero = () => {
setHeroDismissed(true)
setPanelOpen(true)
}
const panelVisible = !isMobile && !showHero && panelOpen
// The panel floats on the right; the canvas ends where it begins
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
return (
<div
className="fixed inset-0 overflow-hidden bg-canvas"
data-testid="workspace"
>
{/* One canvas for both layouts: a new one would reload draw.io
and lose its undo history */}
<div className="absolute inset-0 flex flex-col">
<div className="relative min-h-0 flex-1">
<CanvasStage
className={
isMobile
? cn(
"inset-0",
mobileView !== "canvas" && "invisible",
)
: "inset-y-0 left-0 transition-[right] duration-300 ease-[var(--ease-out)]"
}
style={isMobile ? undefined : { right: canvasRight }}
compact={isMobile}
hideOverlays={!isMobile && showHero}
/>
{isMobile ? (
mobileView === "chat" &&
(showHero ? (
<div className="absolute inset-0 overflow-y-auto bg-canvas">
<div className="flex items-center gap-2 px-4 pt-3">
<BrandMark className="size-6" />
<span className="mr-auto text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
</div>
<LobbyHero
compact
onOpenFile={openFile}
onDrawYourself={() => {
setHeroDismissed(true)
setMobileView("canvas")
}}
/>
</div>
) : (
<ChatPanel mobile onOpenFile={openFile} />
))
) : (
<>
{panelVisible && (
<ChatPanel onOpenFile={openFile} />
)}
{!showHero && !panelOpen && (
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
<BrandMark className="mx-0.5 size-6" />
<IconButton
label={dict.nav.showPanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(true)}
data-testid="show-panel"
>
<PanelRightOpen />
</IconButton>
</div>
)}
{showHero && (
<>
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
<div className="flex min-h-full items-center">
<LobbyHero
onOpenFile={openFile}
onDrawYourself={dismissHero}
/>
</div>
</div>
<div className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float">
<BrandMark className="size-6" />
<span className="pr-1 text-[13px] font-semibold tracking-[-0.01em]">
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
</div>
</>
)}
</>
)}
</div>
{isMobile && (
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
{(
[
{
view: "canvas",
label: dict.workspace.mobileCanvas,
icon: <Shapes />,
},
{
view: "chat",
label: dict.workspace.mobileChat,
icon: <MessageSquare />,
},
] as const
).map((item) => (
<button
key={item.view}
type="button"
onClick={() => setMobileView(item.view)}
aria-pressed={mobileView === item.view}
className={cn(
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
mobileView === item.view &&
"bg-accent font-medium text-foreground",
)}
>
{item.icon}
{item.label}
{item.view === "chat" &&
(isBusy || chatUnread) &&
mobileView !== "chat" && (
<span className="size-1.5 rounded-full bg-marker" />
)}
</button>
))}
</nav>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
setHeroDismissed(true)
engine.openDiagramFile(file)
}
event.target.value = ""
}}
/>
<SettingsDialog />
<CompareDialog />
<SaveDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
engine.langfuseSessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={exportFilename(engine.currentTitle)}
/>
</div>
)
}
+237 -295
View File
@@ -1,69 +1,28 @@
"use client"
import type React from "react"
import { createContext, useCallback, useContext, useRef, useState } from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner"
import type {
DrawioExportEvent,
DrawioFrameHandle,
} from "@/components/canvas/drawio-frame"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import {
canReplaceDiagram,
commitDiagram,
previewDiagram,
resetPreview,
revertPreview,
} from "@/lib/drawio/editor-bridge"
import {
BLANK_MXFILE,
normalizeToMxfile,
} from "@/packages/mcp-server/src/pages.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
/**
* How a diagram reaches the canvas:
* - load: replace the whole document and reset undo (sessions, new chat)
* - preview: streaming AI output, not recorded in undo history
* - commit: final AI result, one undo step; reported to commit listeners
* - revert: drop the streaming preview and go back to the given diagram
*
* preview/commit/revert go through the draw.io editor when it is reachable
* (same origin); otherwise they fall back to a full load.
*/
export type LoadMode = "load" | "preview" | "commit" | "revert"
export interface DiagramCommit {
beforeXml: string
afterXml: string
toolCallId?: string
}
extractDiagramXML,
isRealDiagram,
validateAndFixXml,
} from "../lib/utils"
interface DiagramContextType {
chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.RefObject<string>
latestSvg: string
loadDiagram: (
chart: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string },
) => string | null
// Both return the export's tag (empty when draw.io is not there yet)
handleExport: () => string
handleExportWithoutHistory: () => string
// Pending exports by tag; a plain export's resolver gets the first
// page's XML
exportResolversRef: React.RefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.RefObject<DrawioFrameHandle | null>
handleDiagramExport: (data: DrawioExportEvent) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void
clearDiagram: () => void
saveDiagramToFile: (
filename: string,
@@ -72,55 +31,41 @@ interface DiagramContextType {
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
/** Register the handler told about every committed AI change */
setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** A new user turn starts: forget the streaming base */
startTurn: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Every export carries a tag in the request's `message` field. draw.io
// echoes the request back in the export event, so each result reaches its
// own caller. Tags end in a request number, so a late result never answers
// a newer request.
type ExportTag = "thumbnail" | "validation" | "version"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawioFrameHandle | null>(null)
// Pending exports, keyed by their export tag
const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void>
>({})
const exportSeqRef = useRef(0)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
// Diagram before the current AI change started streaming
const turnBaseRef = useRef<string | null>(null)
const commitHandlerRef = useRef<((commit: DiagramCommit) => void) | null>(
null,
)
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
setIsDrawioReady(true)
// Restore diagram after remount (e.g., language or theme change)
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
drawioRef.current.load({ xml: chartXMLRef.current })
}
}
const resetDrawioReady = () => {
@@ -128,89 +73,123 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Update chartXML and its ref together, so callbacks that read the ref
// (export handler, autosave) see the new value right away
const updateChartXML = (xml: string) => {
chartXMLRef.current = xml
setChartXML(xml)
}
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// The chat's export of the current diagram (onFetchChart)
const handleExport = () => {
if (!drawioRef.current) return ""
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({ format: "xmlsvg", message: tag })
return tag
}
const handleExportWithoutHistory = handleExport
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
// (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => {
clearTimeout(timer)
delete exportResolversRef.current[id]
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = finish
drawioRef.current?.exportDiagram({ format, message: id })
}),
[],
)
// Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save depends on it, and each thumbnail
// renders this provider again (latestSvg), which would otherwise start
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
setLatestSvg(svgData)
return svgData
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
return
}
return null
}, [requestTaggedExport])
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Plain SVG (no embedded diagram data) for version cards
const getVersionSvg = async (): Promise<string | null> => {
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
const handleExport = () => {
if (drawioRef.current) {
// Mark that this export should be saved to history
expectHistoryExportRef.current = true
drawioRef.current.exportDiagram({
format: "xmlsvg",
})
}
}
const handleExportWithoutHistory = () => {
if (drawioRef.current) {
// Export without saving to history (for edit_diagram fetching current state)
drawioRef.current.exportDiagram({
format: "xmlsvg",
})
}
}
// Get current diagram as SVG for thumbnail (used by session storage)
const getThumbnailSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
const svgData = await requestTaggedExport("version", "svg", 4000)
return svgData?.startsWith("data:image/svg") ? svgData : null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try {
const svgData = 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")), 3000),
),
])
// Update latestSvg so it's available for future saves
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
if (!isRealDiagram(chartXMLRef.current)) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
const pngData = await requestTaggedExport("validation", "png", 5000)
return pngData?.startsWith("data:image/png") ? pngData : null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
mode: LoadMode = "load",
meta?: { toolCallId?: string },
): string | null => {
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal
// use). Not strict: the XML may hold the user's own diagram, and the
// tool handlers check model XML strictly before it gets here.
// Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) {
const validation = validateAndFixXml(chart, { strict: false })
const validation = validateAndFixXml(chart)
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
@@ -228,92 +207,72 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
xmlToLoad = withPageDefaults(xmlToLoad)
const previousXml = chartXMLRef.current
if (mode === "preview" && turnBaseRef.current === null) {
turnBaseRef.current = previousXml
}
const beforeXml = turnBaseRef.current ?? previousXml
if (mode !== "preview") turnBaseRef.current = null
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad)
setChartXML(xmlToLoad)
let applied = false
if (mode !== "load" && canReplaceDiagram(xmlToLoad)) {
try {
if (mode === "preview") previewDiagram(xmlToLoad)
else if (mode === "commit") commitDiagram(xmlToLoad)
else revertPreview(xmlToLoad)
applied = true
} catch (error) {
console.warn("[loadDiagram] Editor update failed:", error)
}
}
if (!applied) {
// A full load replaces any preview, and its base is stale now
resetPreview()
drawioRef.current?.load({ xml: xmlToLoad })
}
if (mode === "commit") {
commitHandlerRef.current?.({
beforeXml,
afterXml: xmlToLoad,
toolCallId: meta?.toolCallId,
if (drawioRef.current) {
drawioRef.current.load({
xml: xmlToLoad,
})
}
return null
}
const startTurn = () => {
turnBaseRef.current = null
resetPreview()
}
const handleDiagramExport = (data: DrawioExportEvent) => {
// Thumbnail, version, validation PNG and file save exports go only
// to their own caller
const tag = data.message?.message
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
exportResolversRef.current[tag as string]?.(data.data, data.xml)
const handleDiagramExport = (data: any) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
return
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
// Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data)
saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
if (format === "png" || format === "svg") {
return
}
}
const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data)
// The chat's own export (onFetchChart), not another one in flight
const resolve =
tag !== undefined ? exportResolversRef.current[tag] : undefined
if (resolve) {
delete exportResolversRef.current[tag as string]
resolve(extractedXML)
// 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)
})
expectHistoryExportRef.current = false
}
}
// The frame registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
return
if (resolverRef.current) {
resolverRef.current(extractedXML)
resolverRef.current = null
}
updateChartXML(data.xml)
}
const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(BLANK_MXFILE, true)
loadDiagram(emptyDiagram, true)
setLatestSvg("")
setDiagramHistory([])
}
const saveDiagramToFile = (
@@ -328,88 +287,78 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Map format to draw.io export format
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
const drawioFormat = format === "drawio" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results
const tag = `save-${++exportSeqRef.current}`
exportResolversRef.current[tag] = (
exportData: string,
fullDiagramXML?: string,
) => {
delete exportResolversRef.current[tag]
let fileContent: string | Blob
let mimeType: string
let extension: string
// Set up the resolver before triggering export
saveResolverRef.current = {
resolver: (exportData: string) => {
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format (view-only)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
}
if (format === "drawio") {
// Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData)
let xmlContent = xml
if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
}
fileContent = xmlContent
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else {
// SVG format
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
}
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId)
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId)
// Handle download
let url: string
if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG)
url = fileContent
} else {
const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob)
}
// Handle download
let url: string
if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG)
url = fileContent
} else {
const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob)
}
const a = document.createElement("a")
a.href = url
a.download = `${filename}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
const a = document.createElement("a")
a.href = url
a.download = `${filename}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, { duration: 2500 })
}
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
},
format,
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({
format: drawioFormat,
message: tag,
})
drawioRef.current.exportDiagram({ format: drawioFormat })
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
@@ -429,35 +378,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
const setCommitHandler = (
handler: ((commit: DiagramCommit) => void) | null,
) => {
commitHandlerRef.current = handler
}
return (
<DiagramContext.Provider
value={{
chartXML,
chartXMLRef,
latestSvg,
diagramHistory,
setDiagramHistory,
loadDiagram,
handleExport,
handleExportWithoutHistory,
exportResolversRef,
resolverRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
getVersionSvg,
captureValidationPng,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
setCommitHandler,
startTurn,
showSaveDialog,
setShowSaveDialog,
}}
>
{children}
+8 -7
View File
@@ -1,16 +1,17 @@
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
# draw.io is bundled into the image (public/drawio), no separate container needed
# Uncomment below for subdirectory deployment
# args:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]
+2 -4
View File
@@ -18,11 +18,9 @@
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: 应用现在自带一份 draw.io,从 `/drawio` 提供,浏览器不再需要访问 `embed.diagrams.net`。如果仍然看到这个错误,说明镜像是用 `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(旧版 Dockerfile 的默认值)构建的,去掉这个构建参数重新构建即可。详见[离线部署](./offline-deployment.md)。
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
`NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
**解决方案**: 必须在构建时通过 `args` 传入:
```yaml
# docker-compose.yml
+21 -36
View File
@@ -19,9 +19,7 @@
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -30,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录)
- [示例](#示例)
- [功能特性](#功能特性)
- [MCP服务器](#mcp服务器)
- [MCP服务器(预览)](#mcp服务器预览)
- [Claude Code CLI](#claude-code-cli)
- [快速开始](#快速开始)
- [在线试用](#在线试用)
@@ -56,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG技术图</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
<strong>GCP架构图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>React和AWS认证流程</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
<strong>AWS架构图</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>开放式创新</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
<strong>Azure架构图</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫咪素描</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td>
</tr>
@@ -93,13 +91,14 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**:与AI实时对话来完善您的图表
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器
## MCP服务器(预览)
> **预览功能**:此功能为实验性功能,可能不稳定。
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
@@ -125,13 +124,6 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
图表会实时显示在浏览器中!
MCP服务器包含网页版的大部分画图功能:
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
- 截图工具,AI可以查看画好的图并自行修正
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
## 快速开始
@@ -203,7 +195,7 @@ npm run dev
## 多提供商支持
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock(默认)
- OpenAI
- Anthropic
@@ -212,7 +204,6 @@ npm run dev
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -225,18 +216,12 @@ npm run dev
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
@@ -244,14 +229,14 @@ npm run dev
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
- **react-drawio**:用于图表表示和操作
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 支持与联系
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
-24
View File
@@ -1,24 +0,0 @@
# 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
## 启用面板
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
## 注意事项
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
+4 -133
View File
@@ -13,7 +13,7 @@
### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
> **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
```bash
DOUBAO_API_KEY=your_api_key
@@ -46,23 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
可选的自定义端点:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -70,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
可选的自定义端点:
```bash
@@ -151,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
### OpenRouter
@@ -194,7 +168,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
可选的自定义 URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### Vercel AI Gateway
@@ -233,98 +207,6 @@ AI_MODEL=openai/gpt-4o
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
### MiniMax
MiniMax 支持两种 API 格式:
- **Anthropic 兼容**(`/anthropic` 端点)— 推荐,支持 interleaved thinking
- **OpenAI 兼容**(`/v1` 端点)— 标准 OpenAI 聊天补全格式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
可选配置:
```bash
# 中国大陆版,Anthropic 兼容(默认)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陆版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国际版,Anthropic 兼容
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国际版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (智谱 AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
可选的自定义端点:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (阿里云通义千问)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
可选的自定义端点:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (月之暗面 Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
可选的自定义端点:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (七牛云)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
可选的自定义端点:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (小米)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
@@ -332,7 +214,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
```bash
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## 服务端多模型配置
@@ -353,17 +235,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
### 配置示例
```json
+7 -17
View File
@@ -1,25 +1,13 @@
# 离线部署
Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)会把 draw.io 的发布包下载到 `public/drawio`,只下载一次,应用从 `/drawio` 提供它。使用时浏览器不会访问 `embed.diagrams.net`,所以只要构建时能联网,应用就能在离线网络或内网里使用。
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
## Docker Compose 设置
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
2. 运行 `docker compose up -d`(使用仓库里的 `docker-compose.yml`)。
3. 打开 `http://localhost:3000`。
不需要单独的 draw.io 容器。
## 构建机器不能联网时
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
## 使用单独的 draw.io 服务器(可选)
仍然可以用构建时变量 `NEXT_PUBLIC_DRAWIO_BASE_URL` 让应用使用别处的 draw.io,比如 `jgraph/drawio` 镜像:
2. 创建 `docker-compose.yml`:
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
使用外部 draw.io 时,应用改用 draw.io 自己的工具栏,以下功能不可用:标出 AI 改了哪些图形、针对选中的图形提问、应用自己的画布工具栏、用 Ctrl+Z 撤回 AI 的修改。浏览器不允许页面控制来自其他域名的编辑器。
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
## 配置与重要警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
+2 -4
View File
@@ -18,11 +18,9 @@
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
**Key Point**: The app now ships with its own copy of draw.io, served from `/drawio`, so the browser no longer needs `embed.diagrams.net`. If you still see this error, the image was built with `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net` (the old Dockerfile default). Rebuild without that build argument. See [Offline Deployment](./offline-deployment.md).
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
`NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
**To keep a separate draw.io server instead**, pass it via `args` at build time:
**Solution**: Must pass via `args` at build time:
```yaml
# docker-compose.yml
-24
View File
@@ -1,24 +0,0 @@
# Admin Panel
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
## Enabling the panel
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
## What you can configure
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
## Precedence
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
## Notes
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
+4 -133
View File
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
```bash
DOUBAO_API_KEY=your_api_key
@@ -61,23 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio and other local OpenAI-compatible servers work the same way: set the base URL to `http://localhost:1234/v1` (LM Studio's default port) and use any non-empty API key, such as `lm-studio`. This works both in the environment variables above and in the model settings.
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
Optional custom endpoint:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -85,13 +68,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
@@ -166,11 +142,9 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
@@ -196,7 +170,7 @@ AI_MODEL=llama3.2
Optional custom URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### ModelScope
@@ -248,98 +222,6 @@ Model format uses `provider/model` syntax:
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
### MiniMax
MiniMax supports two API formats:
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
Optional configuration:
```bash
# China mainland, Anthropic-compatible (default)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# China mainland, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# International, Anthropic-compatible
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# International, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
Optional custom endpoint:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
Optional custom endpoint:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
Optional custom endpoint:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
Optional custom endpoint:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 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`.
@@ -347,7 +229,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, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
```
## Server-Side Multi-Model Configuration
@@ -368,17 +250,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
### Example Configuration
```json
+8 -17
View File
@@ -1,25 +1,13 @@
# Offline Deployment
Next AI Draw.io ships with its own copy of draw.io. `npm run build` (and the Docker build) downloads the draw.io release into `public/drawio` once, and the app serves it from `/drawio`. In use, the browser never contacts `embed.diagrams.net`, so the app works on an offline or intranet network as long as the build had internet access.
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. Run `docker compose up -d` (it uses the `docker-compose.yml` in the repository).
3. Open `http://localhost:3000`.
No separate draw.io container is needed.
## Building Without Internet Access
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
- Or build the image on a machine with internet access and transfer it to the offline network.
## Using a Separate draw.io Server (Optional)
You can still point the app at another draw.io, such as the `jgraph/drawio` image, with the build-time variable `NEXT_PUBLIC_DRAWIO_BASE_URL`:
2. Create `docker-compose.yml`:
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
With an external draw.io the app uses draw.io's own toolbar, and these features are off: highlighting what the AI changed, asking about selected shapes, the app's canvas toolbar, and undoing AI changes with Ctrl+Z. Browsers do not let a page control an editor served from a different origin.
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.**
@@ -46,3 +36,4 @@ With an external draw.io the app uses draw.io's own toolbar, and these features
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
+2 -4
View File
@@ -18,11 +18,9 @@
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
**重要**: アプリには draw.io のコピーが同梱され、`/drawio` から配信されるため、ブラウザは `embed.diagrams.net` にアクセスしなくなりました。それでもこのエラーが出る場合は、イメージが `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(以前の Dockerfile の既定値)でビルドされています。このビルド引数を外して再ビルドしてください。詳しくは[オフラインデプロイ](./offline-deployment.md)を参照してください。
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
`NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
**別の draw.io サーバーを使い続ける場合**は、ビルド時に `args` で渡します:
**解決策**: ビルド時に `args` で渡す必要があります:
```yaml
# docker-compose.yml
+21 -34
View File
@@ -19,7 +19,7 @@
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次)
- [例](#例)
- [機能](#機能)
- [MCPサーバー](#mcpサーバー)
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
- [Claude Code CLI](#claude-code-cli)
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す)
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG技術ダイアグラム</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
<strong>GCPアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>ReactとAWSによる認証</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
<strong>AWSアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>オープンイノベーション</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
<strong>Azureアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫のスケッチ</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
</td>
</tr>
@@ -91,13 +91,14 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー
## MCPサーバー(プレビュー)
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
@@ -123,13 +124,6 @@ Claudeにダイアグラムの作成を依頼:
ダイアグラムがリアルタイムでブラウザに表示されます!
MCPサーバーには、Webアプリの主な作図機能が含まれています:
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
- スクリーンショットツール:AIが描画結果を確認して修正できます
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
## はじめに
@@ -202,7 +196,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
## マルチプロバイダーサポート
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock(デフォルト)
- OpenAI
- Anthropic
@@ -211,7 +205,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -224,18 +217,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
@@ -243,14 +230,14 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
- **react-drawio**:ダイアグラムの表現と操作
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
## サポート&お問い合わせ
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
-24
View File
@@ -1,24 +0,0 @@
# 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
## パネルの有効化
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
## 設定できる項目
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
## 注意事項
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
+4 -133
View File
@@ -13,7 +13,7 @@
### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
> **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
```bash
DOUBAO_API_KEY=your_api_key
@@ -46,23 +46,6 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
LM Studio などのローカルな OpenAI 互換サーバーも同じ方法で使えます。ベース URL を `http://localhost:1234/v1`(LM Studio の既定ポート)に設定し、API キーには `lm-studio` など空でない任意の値を入力してください。上の環境変数でも、モデル設定画面でも設定できます。
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
任意のカスタムエンドポイント:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -70,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
任意のカスタムエンドポイント:
```bash
@@ -151,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
### OpenRouter
@@ -181,7 +155,7 @@ AI_MODEL=llama3.2
任意のカスタム URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434/api
OLLAMA_BASE_URL=http://localhost:11434
```
### ModelScope
@@ -233,98 +207,6 @@ AI_MODEL=openai/gpt-4o
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
### MiniMax
MiniMax は 2 つの API 形式をサポートしています:
- **Anthropic 互換**(`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
- **OpenAI 互換**(`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
オプション設定:
```bash
# 中国大陸版、Anthropic 互換(デフォルト)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陸版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国際版、Anthropic 互換
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国際版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
オプションのカスタムエンドポイント:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
オプションのカスタムエンドポイント:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
オプションのカスタムエンドポイント:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
オプションのカスタムエンドポイント:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -332,7 +214,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## サーバーサイドマルチモデル設定
@@ -353,17 +235,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
### 設定例
```json
+8 -18
View File
@@ -1,25 +1,13 @@
# オフラインデプロイ
Next AI Draw.io には draw.io のコピーが同梱されています。`npm run build`(Docker ビルドを含む)が draw.io のリリースを一度だけ `public/drawio` にダウンロードし、アプリは `/drawio` からそれを配信します。利用中のブラウザは `embed.diagrams.net` にアクセスしないため、ビルド時にインターネットに接続できれば、オフライン環境やイントラネットでも動作します。
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
## Docker Compose のセットアップ
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
2. `docker compose up -d` を実行します(リポジトリの `docker-compose.yml` を使います)。
3. `http://localhost:3000` を開きます。
draw.io 用の別コンテナは不要です。
## ビルド環境がインターネットに接続できない場合
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
## 別の draw.io サーバーを使う場合(任意)
ビルド時の変数 `NEXT_PUBLIC_DRAWIO_BASE_URL` で、`jgraph/drawio` イメージなど別の draw.io を使うこともできます。
2. `docker-compose.yml` を作成します。
```yaml
services:
@@ -36,7 +24,9 @@ services:
depends_on: [drawio]
```
外部の draw.io を使うと、アプリは draw.io 自身のツールバーを使い、次の機能は使えなくなります:AI が変更した図形の強調表示、選択した図形についての依頼、アプリのキャンバスツールバー、Ctrl+Z による AI の変更の取り消し。ブラウザは、別のオリジンから配信されたエディターをページから操作することを許可しないためです。
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
## 設定と重要な警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
@@ -45,4 +35,4 @@ services:
| ローカルホスト | `http://localhost:8080` |
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
**Docker の内部エイリアス**(例:`http://drawio:8080`)は**使用しないでください**。ブラウザはそれらを解決できません。
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
+5 -2
View File
@@ -11,6 +11,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
@@ -19,6 +20,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
@@ -28,6 +30,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
@@ -45,7 +48,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
## Enterprise Software
@@ -71,5 +73,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 30 libraries, 4,184 shapes**
**Total: 33 libraries, 4,281 shapes**
-367
View File
@@ -1,367 +0,0 @@
# material_design
**Type:** SVG images (Google Material Icons CDN)
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
</mxCell>
```
Replace `{icon_name}` with any icon name from the list below.
## action (115)
- `account_balance`
- `account_balance_wallet`
- `account_box`
- `account_circle`
- `add_shopping_cart`
- `admin_panel_settings`
- `analytics`
- `arrow_right_alt`
- `article`
- `assessment`
- `assignment`
- `assignment_ind`
- `assignment_turned_in`
- `autorenew`
- `bookmark`
- `bookmark_border`
- `build`
- `calendar_month`
- `calendar_today`
- `card_giftcard`
- `check_circle`
- `check_circle_outline`
- `code`
- `contact_support`
- `credit_card`
- `dashboard`
- `date_range`
- `delete`
- `delete_forever`
- `delete_outline`
- `description`
- `dns`
- `done`
- `done_all`
- `done_outline`
- `drag_indicator`
- `event`
- `exit_to_app`
- `explore`
- `face`
- `fact_check`
- `favorite`
- `favorite_border`
- `feedback`
- `filter_alt`
- `fingerprint`
- `flight_takeoff`
- `grade`
- `help`
- `help_outline`
- `highlight_off`
- `history`
- `home`
- `info`
- `label`
- `language`
- `launch`
- `leaderboard`
- `lightbulb`
- `list`
- `lock`
- `lock_open`
- `login`
- `logout`
- `manage_accounts`
- `note_add`
- `open_in_full`
- `open_in_new`
- `paid`
- `payment`
- `pending`
- `pending_actions`
- `perm_identity`
- `pets`
- `power_settings_new`
- `preview`
- `print`
- `published_with_changes`
- `question_answer`
- `receipt`
- `reorder`
- `report_problem`
- `room`
- `savings`
- `schedule`
- `search`
- `settings`
- `shopping_bag`
- `shopping_basket`
- `shopping_cart`
- `star_rate`
- `stars`
- `store`
- `supervisor_account`
- `swap_horiz`
- `sync_alt`
- `task_alt`
- `thumb_up`
- `thumb_up_off_alt`
- `timeline`
- `tips_and_updates`
- `today`
- `touch_app`
- `trending_up`
- `update`
- `verified`
- `verified_user`
- `view_in_ar`
- `view_list`
- `visibility`
- `visibility_off`
- `watch_later`
- `work`
- `work_outline`
- `zoom_in`
## alert (4)
- `error`
- `error_outline`
- `warning`
- `warning_amber`
## av (12)
- `library_books`
- `mic`
- `pause`
- `play_arrow`
- `play_circle`
- `play_circle_filled`
- `play_circle_outline`
- `replay`
- `skip_next`
- `videocam`
- `volume_off`
- `volume_up`
## communication (13)
- `alternate_email`
- `business`
- `call`
- `chat`
- `chat_bubble_outline`
- `email`
- `forum`
- `list_alt`
- `location_on`
- `mail_outline`
- `phone`
- `qr_code_scanner`
- `vpn_key`
## content (27)
- `add`
- `add_box`
- `add_circle`
- `add_circle_outline`
- `block`
- `bolt`
- `calculate`
- `clear`
- `content_copy`
- `create`
- `filter_list`
- `flag`
- `how_to_reg`
- `insights`
- `inventory`
- `inventory_2`
- `link`
- `mail`
- `push_pin`
- `remove`
- `remove_circle`
- `remove_circle_outline`
- `reply`
- `save`
- `send`
- `sort`
- `undo`
## device (9)
- `dark_mode`
- `devices`
- `light_mode`
- `password`
- `restart_alt`
- `sell`
- `signal_cellular_alt`
- `summarize`
- `task`
## editor (9)
- `attach_file`
- `attach_money`
- `bar_chart`
- `checklist`
- `edit_note`
- `format_list_bulleted`
- `mode_edit`
- `monetization_on`
- `post_add`
## file (8)
- `cloud_upload`
- `download`
- `file_download`
- `file_upload`
- `folder`
- `folder_open`
- `grid_view`
- `upload_file`
## hardware (6)
- `computer`
- `keyboard_arrow_down`
- `keyboard_arrow_right`
- `phone_iphone`
- `security`
- `smartphone`
## image (16)
- `add_a_photo`
- `auto_awesome`
- `auto_stories`
- `circle`
- `collections`
- `edit`
- `image`
- `navigate_before`
- `navigate_next`
- `palette`
- `photo_camera`
- `picture_as_pdf`
- `receipt_long`
- `remove_red_eye`
- `timer`
- `tune`
## maps (11)
- `badge`
- `category`
- `directions_car`
- `local_fire_department`
- `local_offer`
- `local_shipping`
- `map`
- `menu_book`
- `place`
- `restaurant`
- `volunteer_activism`
## navigation (29)
- `apps`
- `arrow_back`
- `arrow_back_ios`
- `arrow_back_ios_new`
- `arrow_downward`
- `arrow_drop_down`
- `arrow_drop_up`
- `arrow_forward`
- `arrow_forward_ios`
- `arrow_right`
- `arrow_upward`
- `campaign`
- `cancel`
- `check`
- `chevron_left`
- `chevron_right`
- `close`
- `double_arrow`
- `east`
- `expand_less`
- `expand_more`
- `fullscreen`
- `menu`
- `menu_open`
- `more_horiz`
- `more_vert`
- `payments`
- `refresh`
- `unfold_more`
## notification (6)
- `account_tree`
- `event_available`
- `priority_high`
- `support_agent`
- `sync`
- `wifi`
## places (2)
- `apartment`
- `storefront`
## search (2)
- `feed`
- `manage_search`
## social (23)
- `construction`
- `emoji_emotions`
- `emoji_events`
- `engineering`
- `group`
- `group_add`
- `groups`
- `health_and_safety`
- `notifications`
- `notifications_active`
- `notifications_none`
- `people`
- `people_alt`
- `person`
- `person_add`
- `person_outline`
- `psychology`
- `public`
- `school`
- `share`
- `thumb_up_alt`
- `travel_explore`
- `water_drop`
## toggle (8)
- `check_box`
- `check_box_outline_blank`
- `radio_button_checked`
- `radio_button_unchecked`
- `star`
- `star_border`
- `star_outline`
- `toggle_on`
Total: 300 icons (top by popularity from 2100+ available)
+23 -45
View File
@@ -67,64 +67,41 @@ const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized JSON response
* Create standardized response with CORS headers
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
// Only the app's own server (/api/chat, /api/validate-model) calls this
// function, so no CORS headers are sent: other sites' pages can't call it
// from a browser and spend the deployment's Edge AI quota.
// Same rule as lib/access-code.ts, but reading the edge function's env.
// No codes configured (or env unavailable) means no check.
function hasValidAccessCode(request: Request, env: any): boolean {
const accessCodes: string[] =
env?.ACCESS_CODE_LIST?.split(",")
.map((code: string) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return true
const accessCode = request.headers.get("x-access-code")
return !!accessCode && accessCodes.includes(accessCode)
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
}
export async function onRequest({ request, env }: any) {
// Requiring JSON also makes any cross-site browser request need a CORS
// preflight, which fails without CORS headers. Only the type before any
// parameters counts: "text/plain; x=application/json" needs none.
const mediaType = (request.headers.get("content-type") ?? "")
.split(";")[0]
.trim()
.toLowerCase()
if (request.method !== "POST" || mediaType !== "application/json") {
return createResponse(
{
error: {
message: "Expected a POST request with a JSON body",
type: "invalid_request_error",
},
},
400,
)
}
if (!hasValidAccessCode(request, env)) {
return createResponse(
{
error: {
message: "Invalid or missing access code",
type: "invalid_request_error",
},
},
401,
)
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
}
request.headers.delete("accept-encoding")
@@ -176,7 +153,7 @@ export async function onRequest({ request, env }: any) {
type: "invalid_request_error",
},
},
400,
429,
)
}
@@ -239,6 +216,7 @@ export async function onRequest({ request, env }: any) {
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {

Some files were not shown because too many files have changed in this diff Show More