mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 17:57:47 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f2fae275 |
+3
-49
@@ -20,61 +20,15 @@ npm run lint # Check lint errors
|
||||
npm run check # Run all checks (CI)
|
||||
```
|
||||
|
||||
Git hooks via Husky run automatically:
|
||||
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
||||
- **Pre-push**: Unit tests
|
||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||
|
||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests before submitting PRs:
|
||||
|
||||
```bash
|
||||
npm run test # Unit tests (Vitest)
|
||||
npm run test:e2e # E2E tests (Playwright)
|
||||
```
|
||||
|
||||
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
|
||||
|
||||
To run a specific test file:
|
||||
```bash
|
||||
npx playwright test tests/e2e/diagram-generation.spec.ts
|
||||
```
|
||||
|
||||
To run tests with UI mode:
|
||||
```bash
|
||||
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
|
||||
2. Make changes (pre-commit runs lint + type check automatically)
|
||||
3. Run E2E tests with `npm run test:e2e`
|
||||
4. Push (pre-push runs unit tests automatically)
|
||||
5. Submit PR against `main` with a clear description
|
||||
|
||||
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.
|
||||
2. Make changes and ensure `npm run check` passes
|
||||
3. Submit PR against `main` with a clear description
|
||||
|
||||
## Issues
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: Enhancement
|
||||
about: Suggest an improvement to existing functionality
|
||||
title: '[Enhancement] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||
|
||||
## Current Behavior
|
||||
Describe how the feature currently works.
|
||||
|
||||
## Proposed Enhancement
|
||||
How you'd like this to be improved.
|
||||
|
||||
## Motivation
|
||||
Why this enhancement would be beneficial.
|
||||
|
||||
## Screenshots / Mockups
|
||||
If applicable, add screenshots or mockups to illustrate the proposed changes.
|
||||
|
||||
## Additional Context
|
||||
Any other information about the enhancement request.
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["after 10am on the first day of the month"],
|
||||
"timezone": "Asia/Tokyo",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "minor and patch dependencies",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "major dependencies",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*"],
|
||||
"groupName": "AI SDK packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@radix-ui/*"],
|
||||
"groupName": "Radix UI packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["electron", "electron-builder"],
|
||||
"groupName": "Electron packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Auto Format
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
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 .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# For fork PRs, just fail if formatting is needed (can't push to forks)
|
||||
- name: Fail if fork PR needs formatting
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
|
||||
git diff --stat
|
||||
exit 1
|
||||
|
||||
# For same-repo PRs, commit and push the changes
|
||||
- name: Commit changes
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||
git add .
|
||||
git commit -m "style: auto-format with Biome"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -54,40 +54,13 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
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
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
|
||||
|
||||
# Push to AWS ECR for App Runner auto-deploy
|
||||
- name: Configure AWS credentials
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ap-northeast-1
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Push to ECR (triggers App Runner auto-deploy)
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REPO_LOWER: ${{ github.repository }}
|
||||
run: |
|
||||
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]')
|
||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
name: Electron Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g., v0.4.5)"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
# Mac and Linux: Build and publish directly (no signing needed)
|
||||
build-mac-linux:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Windows: Build, sign with SignPath, then publish
|
||||
build-windows:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload unsigned artifacts for signing
|
||||
uses: actions/upload-artifact@v6
|
||||
id: upload-unsigned
|
||||
with:
|
||||
name: windows-unsigned
|
||||
path: release/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign with SignPath
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||
project-slug: 'next-ai-draw-io'
|
||||
signing-policy-slug: 'release-signing'
|
||||
artifact-configuration-slug: 'windows-exe'
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: release-signed
|
||||
|
||||
- name: Upload signed artifacts to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-signed/*.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -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
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-unit:
|
||||
name: Lint & Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run check
|
||||
|
||||
- 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
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright deps (cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
-33
@@ -2,8 +2,6 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
packages/*/node_modules
|
||||
packages/*/dist
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@@ -14,8 +12,6 @@ packages/*/dist
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -50,32 +46,3 @@ push-via-ec2.sh
|
||||
.dev.vars
|
||||
.open-next/
|
||||
.wrangler/
|
||||
.env*.local
|
||||
|
||||
# Electron
|
||||
/dist-electron/
|
||||
/release/
|
||||
/electron-standalone/
|
||||
# Draw.io static files (downloaded during CI build)
|
||||
public/drawio/
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
*.snap
|
||||
|
||||
CLAUDE.md
|
||||
.spec-workflow
|
||||
|
||||
# edgeone
|
||||
.edgeone
|
||||
opencode.json
|
||||
ai-models.json
|
||||
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
npx lint-staged
|
||||
npx tsc --noEmit
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Skip if node_modules not installed (e.g., on EC2 push server)
|
||||
if [ -d "node_modules" ]; then
|
||||
npm run test -- --run
|
||||
fi
|
||||
+8
-26
@@ -1,7 +1,7 @@
|
||||
# Multi-stage Dockerfile for Next.js
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-alpine AS deps
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,11 +9,10 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build application
|
||||
FROM node:24-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy node_modules from deps stage
|
||||
@@ -23,29 +22,15 @@ 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
|
||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||
|
||||
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
|
||||
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
|
||||
|
||||
# Stage 3: Production runtime
|
||||
FROM node:24-alpine AS runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -62,9 +47,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
|
||||
@@ -72,6 +54,6 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application (HOSTNAME override needed for AWS App Runner)
|
||||
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 exec node server.js"]
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||
|
||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||
English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
@@ -19,18 +19,6 @@ 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).
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
@@ -38,27 +26,19 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
|
||||
|
||||
## Table of Contents
|
||||
- [Next AI Draw.io](#next-ai-drawio)
|
||||
- [Next AI Draw.io ](#next-ai-drawio-)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
- [Desktop Application](#desktop-application)
|
||||
- [Run with Docker](#run-with-docker)
|
||||
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
||||
- [Installation](#installation)
|
||||
- [Deployment](#deployment)
|
||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||
- [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)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Support \& Contact](#support--contact)
|
||||
- [FAQ](#faq)
|
||||
- [Star History](#star-history)
|
||||
|
||||
## Examples
|
||||
@@ -76,24 +56,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,47 +87,11 @@ 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
|
||||
|
||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"drawio": {
|
||||
"command": "npx",
|
||||
"args": ["@next-ai-drawio/mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code CLI
|
||||
|
||||
```bash
|
||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
```
|
||||
|
||||
Then ask Claude to create diagrams:
|
||||
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
||||
|
||||
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
|
||||
|
||||
### Try it Online
|
||||
@@ -156,19 +100,39 @@ No installation needed! Try the app directly on our demo site:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
|
||||
> Note: Due to high traffic, the demo site currently uses minimax-m2. For best results, we recommend self-hosting with Claude Sonnet 4.5 or Claude Opus 4.5.
|
||||
|
||||
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
|
||||
|
||||
### Desktop Application
|
||||
### Run with Docker (Recommended)
|
||||
|
||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
||||
If you just want to run it locally, the best way is to use Docker.
|
||||
|
||||
Supported platforms: Windows, macOS, Linux.
|
||||
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
||||
|
||||
### Run with Docker
|
||||
Then run:
|
||||
|
||||
[Go to Docker Guide](./docs/en/docker.md)
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e AI_PROVIDER=openai \
|
||||
-e AI_MODEL=gpt-4o \
|
||||
-e OPENAI_API_KEY=your_api_key \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
Or use an env file:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
# Edit .env with your configuration
|
||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
|
||||
|
||||
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./docs/offline-deployment.md) for configuration options.
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -177,85 +141,73 @@ Supported platforms: Windows, macOS, Linux.
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure your AI provider:
|
||||
|
||||
Create a `.env.local` file in the root directory:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
||||
Edit `.env.local` and configure your chosen provider:
|
||||
|
||||
2. Run the development server:
|
||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- Set `AI_MODEL` to the specific model you want to use
|
||||
- Add the required API keys for your provider
|
||||
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
|
||||
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
|
||||
|
||||
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
|
||||
|
||||
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
||||
|
||||
4. Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy to EdgeOne Pages
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js.
|
||||
|
||||
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
||||
|
||||
Deploy by this button:
|
||||
|
||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
|
||||
|
||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||
|
||||
### Deploy on Vercel
|
||||
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
Or you can deploy by this button.
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
|
||||
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
### Deploy on Cloudflare Workers
|
||||
|
||||
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
||||
|
||||
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
|
||||
|
||||
## 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)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Google Vertex AI
|
||||
- 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.
|
||||
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||
|
||||
### 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.
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
|
||||
|
||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||
|
||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||
Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azue, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||
|
||||
|
||||
## How It Works
|
||||
@@ -264,27 +216,37 @@ 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.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/ # Next.js App Router
|
||||
api/chat/ # Chat API endpoint with AI tools
|
||||
page.tsx # Main page with DrawIO embed
|
||||
components/ # React components
|
||||
chat-panel.tsx # Chat interface with diagram control
|
||||
chat-input.tsx # User input component with file upload
|
||||
history-dialog.tsx # Diagram version history viewer
|
||||
ui/ # UI components (buttons, cards, etc.)
|
||||
contexts/ # React context providers
|
||||
diagram-context.tsx # Global diagram state management
|
||||
lib/ # Utility functions and helpers
|
||||
ai-providers.ts # Multi-provider AI configuration
|
||||
utils.ts # XML processing and conversion utilities
|
||||
public/ # Static assets including example images
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||
|
||||
- Email: me[at]jiang.jp
|
||||
|
||||
## FAQ
|
||||
|
||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Instrument_Sans, JetBrains_Mono } 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",
|
||||
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({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
// Generate static params for all locales
|
||||
export async function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ lang: locale }))
|
||||
}
|
||||
|
||||
// Generate metadata per locale
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
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
|
||||
|
||||
// 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 {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
keywords: [
|
||||
"AI diagram generator",
|
||||
"AWS architecture",
|
||||
"flowchart creator",
|
||||
"draw.io",
|
||||
"AI drawing tool",
|
||||
"technical diagrams",
|
||||
"diagram automation",
|
||||
"free diagram generator",
|
||||
"online diagram maker",
|
||||
],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
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",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Next AI Draw.io - AI-powered diagram creation tool",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
alternates: {
|
||||
languages: {
|
||||
en: "/en",
|
||||
zh: "/zh",
|
||||
ja: "/ja",
|
||||
"zh-Hant": "/zh-Hant",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
params: Promise<{ lang: string }>
|
||||
}>) {
|
||||
const { lang } = await params
|
||||
if (!hasLocale(lang)) notFound()
|
||||
const validLang = lang as Locale
|
||||
const dictionary = await getDictionary(validLang)
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Next AI Draw.io",
|
||||
applicationCategory: "DesignApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
description:
|
||||
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
inLanguage: validLang,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
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`}
|
||||
>
|
||||
<DictionaryProvider dictionary={dictionary}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</DictionaryProvider>
|
||||
<AppToaster />
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
)}
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Suspense } from "react"
|
||||
import { Workspace } from "@/components/workspace/workspace"
|
||||
|
||||
export default function Home() {
|
||||
// The workspace reads ?session= from the URL, which needs a Suspense boundary
|
||||
return (
|
||||
<Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
|
||||
<Workspace />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function AboutCN() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -61,73 +72,147 @@ export default function AboutCN() {
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
由字节跳动豆包提供支持
|
||||
模型变更与用量限制{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(或者说:我的钱包顶不住了)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
好消息!感谢{" "}
|
||||
<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"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||
大家对这个项目的热情太高了——看来大家都真的很喜欢画图!但这也带来了一个幸福的烦恼:我们经常触发出上游
|
||||
AI 接口的频率限制
|
||||
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
||||
</p>
|
||||
<p>
|
||||
由于使用量过高,我已将模型从 Claude 更换为{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
50万免费Token
|
||||
minimax-m2
|
||||
</span>
|
||||
,适用于所有模型!
|
||||
,以降低成本。
|
||||
</p>
|
||||
<p>
|
||||
作为一个
|
||||
<span className="font-semibold text-amber-700">
|
||||
独立开发者
|
||||
</span>
|
||||
,目前的 API
|
||||
费用全是我自己在掏腰包(纯属为爱发电)。为了保证服务能细水长流,同时也为了避免我个人陷入财务危机,我还设置了以下临时用量限制:
|
||||
</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>
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Token 用量
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/天
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
每日请求数
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<div className="text-center mb-5">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
使用自己的 API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
您也可以使用自己的 API
|
||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
||||
您可以使用自己的 API Key
|
||||
来绕过这些限制。点击聊天面板中的设置图标即可配置您的
|
||||
Provider 和 API Key。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
您的 Key
|
||||
仅保存在浏览器本地,不会被存储在服务器上。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
寻求赞助 (求大佬捞一把)
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
要想彻底解除这些限制,扩容后端是唯一的办法。我正在积极寻求
|
||||
AI API 提供商或云平台的赞助。
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
作为回报(无论是额度支持还是资金支持),我将在
|
||||
GitHub 仓库和 Live Demo
|
||||
网站的显眼位置展示贵公司的 Logo
|
||||
作为平台赞助商。
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
联系我
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,10 +234,8 @@ export default function AboutCN() {
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>版本与撤销</strong>
|
||||
:AI
|
||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
||||
Ctrl+Z 也能撤回
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
@@ -177,106 +260,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'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 +365,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">
|
||||
@@ -309,16 +377,6 @@ export default function AboutCN() {
|
||||
多提供商支持
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(默认)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
@@ -326,13 +384,10 @@ export default function AboutCN() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
@@ -340,21 +395,18 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
支持与联系
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>{" "}
|
||||
为本站提供 API Token 支持!
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
支持与联系
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a
|
||||
@@ -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",
|
||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
||||
],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function AboutJA() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -69,54 +80,144 @@ export default function AboutJA() {
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
ByteDance Doubao提供
|
||||
モデル変更と利用制限について{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(別名:お財布が悲鳴を上げています)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
朗報です!
|
||||
<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"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
様のご支援により、デモサイトでは強力な{" "}
|
||||
予想以上の反響をいただき、ありがとうございます!皆様にダイアグラム作成を楽しんでいただいているのは嬉しい限りですが、その熱量により
|
||||
AI API のレート制限 (TPS/TPM)
|
||||
に頻繁に引っかかってしまっています。制限に達するとシステムが一時停止し、エラーが発生してしまいます。
|
||||
</p>
|
||||
<p>
|
||||
利用量の増加に伴い、コスト削減のためモデルを
|
||||
Claude から{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
minimax-m2
|
||||
</span>{" "}
|
||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||
に変更しました。
|
||||
</p>
|
||||
<p>
|
||||
私は現在、
|
||||
<span className="font-semibold text-amber-700">
|
||||
50万トークン
|
||||
個人開発者
|
||||
</span>
|
||||
が無料でもらえます!
|
||||
として API
|
||||
費用を全額自腹で負担しています。サービスを継続し、かつ私自身が借金を背負わないようにするため(笑)、一時的に以下の利用制限も設けさせていただきました。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
トークン使用量
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/分
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
1日のリクエスト数
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
回
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<div className="text-center mb-5">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
自分のAPIキーを使用
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
||||
自分のAPIキーを使用することで、これらの制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
スポンサー募集
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
これらの制限を取り払い、バックエンドをスケールさせるには皆様の支援が必要です。現在、AI
|
||||
API
|
||||
プロバイダー様やクラウドプラットフォーム様からのスポンサー支援を積極的に募集しています。
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
ご支援(クレジット提供や資金援助)をいただける場合、GitHub
|
||||
リポジトリおよびデモサイトにて、プラットフォームスポンサーとして貴社を大々的にご紹介させていただきます。
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
お問い合わせ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,10 +239,8 @@ export default function AboutJA() {
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>バージョンと取り消し</strong>
|
||||
:AI
|
||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
||||
Ctrl+Z を押しても元に戻せます
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
@@ -170,106 +269,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'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 +378,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">
|
||||
@@ -305,16 +391,6 @@ export default function AboutJA() {
|
||||
マルチプロバイダーサポート
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(デフォルト)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
@@ -322,13 +398,10 @@ export default function AboutJA() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
@@ -336,21 +409,18 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
様に、心より感謝申し上げます。
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a
|
||||
@@ -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",
|
||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
||||
],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -69,60 +80,157 @@ export default function About() {
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
Sponsored by ByteDance Doubao
|
||||
Model Change & Usage Limits{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(Or: Why My Wallet is Crying)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
, the demo site now uses the powerful{" "}
|
||||
The response to this project has been
|
||||
incredible—you all love making diagrams!
|
||||
However, this enthusiasm means we are
|
||||
frequently hitting the AI API rate limits
|
||||
(TPS/TPM). When this happens, the system
|
||||
pauses, leading to failed requests.
|
||||
</p>
|
||||
<p>
|
||||
Due to the high usage, I have changed the
|
||||
model from Claude to{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
model for better diagram generation! Sign up
|
||||
via the link to get{" "}
|
||||
minimax-m2
|
||||
</span>
|
||||
, which is more cost-effective.
|
||||
</p>
|
||||
<p>
|
||||
As an{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
500K free tokens
|
||||
</span>{" "}
|
||||
for all models!
|
||||
indie developer
|
||||
</span>
|
||||
, I am currently footing the entire API
|
||||
bill. To keep the lights on and ensure the
|
||||
service remains available to everyone
|
||||
without sending me into debt, I have also
|
||||
implemented the following temporary caps:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Token Usage
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/min
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Daily Requests
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
requests
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<div className="text-center mb-5">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Bring Your Own API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
You can also use your own API key with any
|
||||
supported provider. Click the Settings icon
|
||||
in the chat panel to configure your provider
|
||||
and API key.
|
||||
You can use your own API key to bypass these
|
||||
limits. Click the Settings icon in the chat
|
||||
panel to configure your provider and API
|
||||
key.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
Your key is stored locally in your browser
|
||||
and is never stored on the server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Call for Sponsorship
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
Scaling the backend is the only way to
|
||||
remove these limits. I am actively seeking
|
||||
sponsorship from AI API providers or Cloud
|
||||
Platforms.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
In return for support (credits or funding),
|
||||
I will prominently feature your company as a
|
||||
platform sponsor on both the GitHub
|
||||
repository and the live demo site.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Contact Me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,10 +258,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 +290,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'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'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 +402,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">
|
||||
@@ -319,16 +417,6 @@ export default function About() {
|
||||
Multi-Provider Support
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
@@ -336,13 +424,10 @@ export default function About() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
@@ -352,21 +437,18 @@ export default function About() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
for sponsoring the API token usage of the demo site!
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
<a
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
+253
-569
@@ -3,68 +3,97 @@ import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
InvalidToolInputError,
|
||||
LoadAPIKeyError,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import {
|
||||
CACHE_POINT,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
formatSelectionContext,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import {
|
||||
endTrace,
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
|
||||
import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import {
|
||||
type FlattenedServerModel,
|
||||
findServerModelById,
|
||||
} from "@/lib/server-model-config"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
getShapeLibrary,
|
||||
SHAPE_LIBRARY_LIST,
|
||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
||||
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||
export const maxDuration = 120
|
||||
|
||||
// File upload limits (must match client-side)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
|
||||
// Helper function to validate file parts in messages
|
||||
function validateFileParts(messages: any[]): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const fileParts =
|
||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||
|
||||
if (fileParts.length > MAX_FILES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePart of fileParts) {
|
||||
// Data URLs format: data:image/png;base64,<data>
|
||||
// Base64 increases size by ~33%, so we check the decoded size
|
||||
if (filePart.url?.startsWith("data:")) {
|
||||
const base64Data = filePart.url.split(",")[1]
|
||||
if (base64Data) {
|
||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Helper function to check if diagram is minimal/empty
|
||||
function isMinimalDiagram(xml: string): boolean {
|
||||
const stripped = xml.replace(/\s/g, "")
|
||||
return !stripped.includes('id="2"')
|
||||
}
|
||||
|
||||
// Helper function to replace historical tool call XML with placeholders
|
||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||
function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||
return messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const replacedContent = msg.content.map((part: any) => {
|
||||
if (part.type === "tool-call") {
|
||||
const toolName = part.toolName
|
||||
if (
|
||||
toolName === "display_diagram" ||
|
||||
toolName === "edit_diagram"
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
input: {
|
||||
placeholder:
|
||||
"[XML content replaced - see current diagram XML in system context]",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
return { ...msg, content: replacedContent }
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
@@ -96,28 +125,30 @@ function createCachedStreamResponse(xml: string): Response {
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
}
|
||||
|
||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||
const modelStreamResponses = new WeakSet<Response>()
|
||||
|
||||
// Inner handler function
|
||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Check for access code
|
||||
const accessDenied = checkAccessCode(req)
|
||||
if (accessDenied) return accessDenied
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, xml, previousXml, sessionId } = body
|
||||
const customSystemMessage =
|
||||
typeof body.customSystemMessage === "string"
|
||||
? body.customSystemMessage.slice(0, 5000)
|
||||
: ""
|
||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||
|
||||
// Get user ID for Langfuse tracking and quota
|
||||
const userId = getUserIdFromRequest(req)
|
||||
// Get user IP for Langfuse tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||
const validSessionId =
|
||||
@@ -126,12 +157,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
: undefined
|
||||
|
||||
// Extract user input text for Langfuse trace
|
||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.role === "user")
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const userInputText =
|
||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
@@ -149,7 +177,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
||||
|
||||
if (isFirstMessage && isEmptyDiagram) {
|
||||
const lastMessage = messages[0]
|
||||
@@ -165,173 +193,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// === CACHE CHECK END ===
|
||||
|
||||
// Read client AI provider overrides from headers
|
||||
const provider = req.headers.get("x-ai-provider")
|
||||
let baseUrl = req.headers.get("x-ai-base-url")
|
||||
const selectedModelId = req.headers.get("x-selected-model-id")
|
||||
|
||||
// Check if this is a server model with custom env var names
|
||||
let serverModelConfig: {
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
provider?: string
|
||||
} = {}
|
||||
let serverModel: FlattenedServerModel | null = null
|
||||
if (selectedModelId?.startsWith("server:")) {
|
||||
serverModel = await findServerModelById(selectedModelId)
|
||||
console.log(
|
||||
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
||||
)
|
||||
if (serverModel) {
|
||||
serverModelConfig = {
|
||||
apiKeyEnv: serverModel.apiKeyEnv,
|
||||
baseUrlEnv: serverModel.baseUrlEnv,
|
||||
// Use actual provider from config (client header may have incorrect value due to ID format change)
|
||||
provider: serverModel.provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return Response.json(
|
||||
{ error: "Private or internal base URLs are not allowed." },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
baseUrl,
|
||||
provider: req.headers.get("x-ai-provider"),
|
||||
baseUrl: req.headers.get("x-ai-base-url"),
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
modelId: serverModel?.modelId || req.headers.get("x-ai-model"),
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||
awsRegion: req.headers.get("x-aws-region"),
|
||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||
// Server model custom env var names
|
||||
...serverModelConfig,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(isEdgeOne && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
modelId: req.headers.get("x-ai-model"),
|
||||
}
|
||||
|
||||
// Read minimal style preference from header
|
||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
||||
|
||||
console.log(
|
||||
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
|
||||
)
|
||||
|
||||
// Get AI model with optional client overrides
|
||||
const {
|
||||
model: baseModel,
|
||||
providerOptions,
|
||||
modelId,
|
||||
provider: resolvedProvider,
|
||||
} = getAIModel(clientOverrides)
|
||||
|
||||
// On the server's own keys, only run models the server offers: a server
|
||||
// model picked by id (its model name is fixed above) or one in AI_MODEL
|
||||
// on AI_PROVIDER. With their own key, users can run any model.
|
||||
const onServerCredentials = usesServerCredentials(
|
||||
resolvedProvider,
|
||||
clientOverrides,
|
||||
)
|
||||
const envModels =
|
||||
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
|
||||
const offeredInEnv =
|
||||
envModels.includes(modelId) && resolvedProvider === getServerProvider()
|
||||
if (onServerCredentials && !serverModel && !offeredInEnv) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
|
||||
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
|
||||
// Ollama, and anything at a private address (the server's network,
|
||||
// which ignores a dummy key header). Bedrock and EdgeOne never use the
|
||||
// base URL header. In the desktop app every endpoint is the user's.
|
||||
const clientBaseUrl = normalizeBaseUrl(
|
||||
req.headers.get("x-ai-base-url") ?? "",
|
||||
)
|
||||
const onServerEndpoint = await usesServerEndpoint(
|
||||
resolvedProvider,
|
||||
clientBaseUrl,
|
||||
clientOverrides.apiKey,
|
||||
)
|
||||
const countsQuota =
|
||||
isQuotaEnabled() &&
|
||||
(onServerCredentials || onServerEndpoint) &&
|
||||
userId !== "anonymous"
|
||||
if (countsQuota) {
|
||||
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 Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// Retry once if the provider rejects the requested budget, or (newer
|
||||
// Claude models) the sampling or thinking settings
|
||||
const model = withOutputTokenLimitFallback(
|
||||
withDeprecatedParamsFallback(baseModel),
|
||||
)
|
||||
|
||||
// The user setting can raise the budget only on their own key (in the
|
||||
// desktop app every key is the user's); on the server's keys or own
|
||||
// endpoints it can only lower it
|
||||
const maxOutputTokens = resolveMaxOutputTokens(
|
||||
req.headers.get("x-max-output-tokens"),
|
||||
onServerCredentials || onServerEndpoint,
|
||||
)
|
||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||
const { model, providerOptions, headers, modelId } =
|
||||
getAIModel(clientOverrides)
|
||||
|
||||
// Check if model supports prompt caching
|
||||
const shouldCache = supportsPromptCaching(modelId)
|
||||
@@ -340,20 +211,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
)
|
||||
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||
const finalSystemMessage = customSystemMessage
|
||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||
: systemMessage
|
||||
const systemMessage = getSystemPrompt(modelId)
|
||||
|
||||
// Extract file parts (images) from the last user message
|
||||
// Extract file parts (images) from the last message
|
||||
const fileParts =
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
|
||||
// Note: we used to pre-emptively reject images for models we guessed were
|
||||
// text-only (by name matching). That heuristic misfired on newer models
|
||||
// (see issue #874), so we now let the request through and surface the real
|
||||
// provider error if the model genuinely can't accept images.
|
||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
@@ -361,45 +223,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
${userInputText}
|
||||
"""`
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||
// call that never got its result (the user stopped while it ran) is
|
||||
// left out: the SDK would refuse this and every later request of the
|
||||
// chat (MissingToolResultsError)
|
||||
const modelMessages = await convertToModelMessages(messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
})
|
||||
|
||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||
messages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] Message ${idx} role:`,
|
||||
msg.role,
|
||||
"parts count:",
|
||||
msg.parts?.length,
|
||||
)
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-invocation" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input && typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = convertToModelMessages(messages)
|
||||
|
||||
// Replace historical tool call XML with placeholders to reduce tokens
|
||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
||||
@@ -416,45 +241,6 @@ ${userInputText}
|
||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
||||
)
|
||||
|
||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||
|
||||
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] ModelMsg ${idx} role:`,
|
||||
msg.role,
|
||||
"content count:",
|
||||
msg.content?.length,
|
||||
)
|
||||
if (msg.content) {
|
||||
msg.content.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-call" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputValue:
|
||||
part.input === undefined
|
||||
? "undefined"
|
||||
: part.input === null
|
||||
? "null"
|
||||
: "object",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update the last message with user input only (XML moved to separate cached system message)
|
||||
if (enhancedMessages.length >= 1) {
|
||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
|
||||
@@ -469,7 +255,7 @@ ${userInputText}
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mediaType: filePart.mediaType,
|
||||
mimeType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,7 +275,9 @@ ${userInputText}
|
||||
if (enhancedMessages[i].role === "assistant") {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: CACHE_POINT,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}
|
||||
break // Only cache the last assistant message
|
||||
}
|
||||
@@ -497,117 +285,44 @@ ${userInputText}
|
||||
}
|
||||
|
||||
// System messages with multiple cache breakpoints for optimal caching:
|
||||
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
|
||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// Some providers (e.g. MiniMax) don't support multiple system messages
|
||||
// Merge them into a single system message for compatibility
|
||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||
const isCustomOpenAIEndpoint =
|
||||
resolvedProvider === "openai" &&
|
||||
!!(
|
||||
baseUrl ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
(serverModelConfig.baseUrlEnv &&
|
||||
process.env[serverModelConfig.baseUrlEnv])
|
||||
)
|
||||
const isSingleSystemProvider =
|
||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||
|
||||
const selectionContext = formatSelectionContext(body.selectedCells)
|
||||
const xmlContext = `${
|
||||
previousXml
|
||||
? `Previous diagram XML (before user's last message):
|
||||
"""xml
|
||||
${previousXml}
|
||||
"""
|
||||
|
||||
`
|
||||
: ""
|
||||
}Current diagram XML (AUTHORITATIVE - the source of truth):
|
||||
"""xml
|
||||
${xml || ""}
|
||||
"""
|
||||
|
||||
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
|
||||
|
||||
const systemMessages = isSingleSystemProvider
|
||||
? [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `${finalSystemMessage}\n\n${xmlContext}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
// Cache breakpoint 1: Instructions (+ optional custom instructions)
|
||||
{
|
||||
role: "system" as const,
|
||||
content: finalSystemMessage,
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: xmlContext,
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
]
|
||||
// This allows: if only user message changes, both system caches are reused
|
||||
// if XML changes, instruction cache is still reused
|
||||
const systemMessages = [
|
||||
// Cache breakpoint 1: Instructions (rarely change)
|
||||
{
|
||||
role: "system" as const,
|
||||
content: systemMessage,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
// Set by onAbort, which records the finished steps' tokens itself
|
||||
let stopped = false
|
||||
const result = streamText({
|
||||
model,
|
||||
// The system messages carry cache points, so they go in messages.
|
||||
// A client's own system messages have string content and were
|
||||
// dropped by the empty-content filter above.
|
||||
allowSystemInMessages: true,
|
||||
abortSignal: req.signal,
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
maxOutputTokens,
|
||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||
}),
|
||||
stopWhen: stepCountIs(5),
|
||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||
// DEBUG: Log what we're trying to repair
|
||||
console.log(`[repairToolCall] Tool: ${toolCall.toolName}`)
|
||||
console.log(
|
||||
`[repairToolCall] Error: ${error.name} - ${error.message}`,
|
||||
)
|
||||
console.log(`[repairToolCall] Input type: ${typeof toolCall.input}`)
|
||||
console.log(`[repairToolCall] Input value:`, toolCall.input)
|
||||
|
||||
// Only attempt repair for invalid tool input (broken JSON from truncation)
|
||||
if (
|
||||
error instanceof InvalidToolInputError ||
|
||||
error.name === "AI_InvalidToolInputError"
|
||||
) {
|
||||
try {
|
||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
|
||||
// then use jsonrepair to fix truncated JSON
|
||||
const repairedInput = jsonrepair(
|
||||
fixToolInputJson(toolCall.input),
|
||||
)
|
||||
console.log(
|
||||
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
|
||||
)
|
||||
return { ...toolCall, input: repairedInput }
|
||||
} catch (repairError) {
|
||||
console.warn(
|
||||
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
|
||||
repairError,
|
||||
)
|
||||
// Keep the original error, so the model and the client see why
|
||||
// the input was rejected and the model can retry the call
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Don't attempt to repair other errors (like NoSuchToolError)
|
||||
return null
|
||||
},
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
@@ -615,57 +330,46 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
userId,
|
||||
}),
|
||||
}),
|
||||
onFinish: ({ text, totalUsage }) => {
|
||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||
setTraceOutput(text)
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||
if (countsQuota && totalUsage && !stopped) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
},
|
||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||
onError: ({ error }) => {
|
||||
console.error(error) // what AI SDK does without an onError
|
||||
endTrace()
|
||||
},
|
||||
onAbort: ({ steps }) => {
|
||||
stopped = true
|
||||
endTrace()
|
||||
// Stopped (or disconnected) after some steps finished: their
|
||||
// tokens were used, or stopping every request after a costly
|
||||
// first step would get around the token limits
|
||||
if (countsQuota) {
|
||||
const tokens = steps.reduce(
|
||||
(sum, step) =>
|
||||
sum +
|
||||
(step.usage.inputTokens || 0) +
|
||||
(step.usage.outputTokens || 0),
|
||||
0,
|
||||
)
|
||||
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||
}
|
||||
onFinish: ({ text, usage }) => {
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
})
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
||||
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. All mxCell elements must be siblings - never nested
|
||||
4. Every mxCell needs a unique id (start from "2")
|
||||
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
||||
6. Escape special chars in values: < > & "
|
||||
1. All mxCell elements must be DIRECT children of <root> - never nested
|
||||
2. Every mxCell needs a unique id
|
||||
3. Every mxCell (except id="0") needs a valid parent attribute
|
||||
4. Edge source/target must reference existing cell IDs
|
||||
5. Escape special chars in values: < > & "
|
||||
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
${SWIMLANE_EXAMPLE}
|
||||
Example with swimlanes and edges (note: all mxCells are siblings):
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
@@ -678,136 +382,121 @@ Notes:
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
|
||||
IMPORTANT: Keep edits concise:
|
||||
- COPY the exact mxCell line from the current XML (attribute order matters!)
|
||||
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
|
||||
- Break large changes into multiple smaller edits
|
||||
- Each search must contain complete lines (never truncate mid-line)
|
||||
- First match only - be specific enough to target the right element
|
||||
|
||||
Operations:
|
||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
|
||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
Example - Add a rectangle:
|
||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||
|
||||
Example - Delete container (children & edges auto-deleted):
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
||||
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
|
||||
inputSchema: z.object({
|
||||
operations: z
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe(
|
||||
"Operation to perform: add, update, or delete",
|
||||
),
|
||||
cell_id: z
|
||||
search: z
|
||||
.string()
|
||||
.describe(
|
||||
"The id of the mxCell. Must match the id attribute in new_xml.",
|
||||
"EXACT lines copied from current XML (preserve attribute order!)",
|
||||
),
|
||||
new_xml: z
|
||||
replace: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Complete mxCell XML element (required for update/add)",
|
||||
),
|
||||
.describe("Replacement lines"),
|
||||
}),
|
||||
)
|
||||
.describe("Array of operations to apply"),
|
||||
}),
|
||||
},
|
||||
append_diagram: {
|
||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||
|
||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe(
|
||||
"Continuation XML fragment to append (NO wrapper tags)",
|
||||
"Array of search/replace pairs to apply sequentially",
|
||||
),
|
||||
}),
|
||||
},
|
||||
get_shape_library: {
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
${SHAPE_LIBRARY_LIST}
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
library: z
|
||||
.string()
|
||||
.describe(
|
||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Only known library names reach the file system
|
||||
const result = await getShapeLibrary(
|
||||
library,
|
||||
path.join(process.cwd(), "docs/shape-libraries"),
|
||||
)
|
||||
return result.ok ? result.text : result.error
|
||||
},
|
||||
},
|
||||
},
|
||||
...(process.env.TEMPERATURE !== undefined && {
|
||||
temperature: parseFloat(process.env.TEMPERATURE),
|
||||
}),
|
||||
})
|
||||
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
return result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
// On the server's keys the provider's text can name its account.
|
||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||
// user's own, and EdgeOne's text is our function's explanation.
|
||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
// AI SDK 6 provides totalTokens directly
|
||||
if (!usage) {
|
||||
console.warn(
|
||||
"[messageMetadata] No usage data in finish part",
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
// Total input = non-cached + cached (these are separate counts)
|
||||
// Note: cacheWriteInputTokens is not available on finish part
|
||||
const totalInputTokens =
|
||||
(usage.inputTokens ?? 0) + (usage.cachedInputTokens ?? 0)
|
||||
return {
|
||||
totalTokens: usage?.totalTokens ?? 0,
|
||||
finishReason: (part as any).finishReason,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
modelStreamResponses.add(response)
|
||||
return response
|
||||
}
|
||||
|
||||
// Errors before the stream starts, as JSON the chat panel reads
|
||||
// Helper to categorize errors and return appropriate response
|
||||
function handleError(error: unknown): Response {
|
||||
console.error("Error in chat route:", error)
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
const classified = classifyLLMError(error)
|
||||
const status =
|
||||
(error as { statusCode?: number })?.statusCode ||
|
||||
(error as { status?: number })?.status ||
|
||||
(classified.code === "invalid_api_key" ? 401 : 500)
|
||||
|
||||
// Check for specific AI SDK error types
|
||||
if (APICallError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: error.message,
|
||||
...(isDev && {
|
||||
details: error.responseBody,
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: error.statusCode || 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Authentication failed. Please check your API key.",
|
||||
...(isDev && {
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for other errors with safety filter
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
const status = (error as any)?.statusCode || (error as any)?.status || 500
|
||||
|
||||
// Prevent leaking API keys, tokens, or other sensitive data
|
||||
const lowerMessage = message.toLowerCase()
|
||||
const safeMessage =
|
||||
lowerMessage.includes("key") ||
|
||||
lowerMessage.includes("token") ||
|
||||
lowerMessage.includes("sig") ||
|
||||
lowerMessage.includes("signature") ||
|
||||
lowerMessage.includes("secret") ||
|
||||
lowerMessage.includes("password") ||
|
||||
lowerMessage.includes("credential")
|
||||
? "Authentication failed. Please check your credentials."
|
||||
: message
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
...classified,
|
||||
error: safeMessage,
|
||||
...(isDev && {
|
||||
details: APICallError.isInstance(error)
|
||||
? error.responseBody
|
||||
: undefined,
|
||||
details: message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}),
|
||||
},
|
||||
@@ -817,16 +506,11 @@ function handleError(error: unknown): Response {
|
||||
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await handleChatRequest(req)
|
||||
return await handleChatRequest(req)
|
||||
} catch (error) {
|
||||
response = handleError(error)
|
||||
return handleError(error)
|
||||
}
|
||||
// Early returns, cache hits and errors never reach streamText's callbacks,
|
||||
// so their Langfuse trace has to be ended here
|
||||
if (!modelStreamResponses.has(response)) endTrace()
|
||||
return response
|
||||
}
|
||||
|
||||
// Wrap with Langfuse observe (if configured)
|
||||
|
||||
+11
-10
@@ -81,15 +81,16 @@ Contains the actual diagram data.
|
||||
|
||||
## Root Cell Container: `<root>`
|
||||
|
||||
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
|
||||
Contains all the cells in the diagram.
|
||||
|
||||
**Internal structure (auto-generated):**
|
||||
**Example:**
|
||||
|
||||
```xml
|
||||
<root>
|
||||
<mxCell id="0"/> <!-- Auto-added -->
|
||||
<mxCell id="1" parent="0"/> <!-- Auto-added -->
|
||||
<!-- Your mxCell elements go here (start from id="2") -->
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<!-- Other cells go here -->
|
||||
</root>
|
||||
```
|
||||
|
||||
@@ -202,15 +203,15 @@ Draw.io files contain two special cells that are always present:
|
||||
1. **Root Cell** (id = "0"): The parent of all cells
|
||||
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
|
||||
|
||||
## Tips for Creating Draw.io XML
|
||||
## Tips for Manually Creating Draw.io XML
|
||||
|
||||
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
|
||||
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
|
||||
1. Start with the basic structure (`mxfile`, `diagram`, `mxGraphModel`, `root`)
|
||||
2. Always include the two special cells (id = "0" and id = "1")
|
||||
3. Assign unique and sequential IDs to all cells
|
||||
4. Define parent relationships correctly (use parent="1" for top-level shapes)
|
||||
4. Define parent relationships correctly
|
||||
5. Use `mxGeometry` elements to position shapes
|
||||
6. For connectors, specify `source` and `target` attributes
|
||||
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
|
||||
7. **CRITICAL: All mxCell elements must be DIRECT children of `<root>`. NEVER nest mxCell inside another mxCell.**
|
||||
|
||||
## Common Patterns
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
messageId: z.string().min(1).max(200),
|
||||
@@ -28,13 +27,9 @@ export async function POST(req: Request) {
|
||||
|
||||
const { messageId, feedback, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Get user ID for tracking
|
||||
const userId = getUserIdFromRequest(req)
|
||||
// Get user IP for tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -27,11 +27,6 @@ export async function POST(req: Request) {
|
||||
|
||||
const { filename, format, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { extractFromHtml } 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"
|
||||
|
||||
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()
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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.
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
|
||||
let html: string
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
redirect: "error",
|
||||
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(
|
||||
{ error: "Timed out while fetching URL content" },
|
||||
{ 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 },
|
||||
)
|
||||
} 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) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
})
|
||||
|
||||
// Remove unwanted elements before conversion
|
||||
turndownService.remove(["script", "style", "iframe", "noscript"])
|
||||
|
||||
const markdown = turndownService.turndown(article.content)
|
||||
|
||||
// Check content length
|
||||
if (markdown.length > MAX_CONTENT_LENGTH) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
title: article.title || "Untitled",
|
||||
content: markdown,
|
||||
charCount: markdown.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("URL extraction error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch or parse URL content" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
||||
|
||||
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
||||
// This ensures Docker users can set these values when starting containers
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const models = await loadFlattenedServerModels()
|
||||
return NextResponse.json({
|
||||
models,
|
||||
hasConfig: models.length > 0,
|
||||
})
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* API endpoint for VLM-based diagram validation.
|
||||
* 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 { 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,
|
||||
ValidationResultSchema,
|
||||
} from "@/lib/validation-schema"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Default valid result for disabled/error cases
|
||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
/** A fixed result in the text format useObject reads */
|
||||
function createStreamingResponse(result: ValidationResult): Response {
|
||||
return new Response(JSON.stringify(result), {
|
||||
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"
|
||||
if (!enableValidation) {
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
const body: ValidateDiagramRequest = await req.json()
|
||||
const { imageData, sessionId } = body
|
||||
|
||||
if (!imageData) {
|
||||
return Response.json(
|
||||
{ error: "Missing imageData" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate image data format
|
||||
if (
|
||||
!imageData.startsWith("data:image/png;base64,") &&
|
||||
!imageData.startsWith("data:image/")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Invalid image data format" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
model = getValidationModel()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[validate-diagram] Validation model not available:",
|
||||
error,
|
||||
)
|
||||
// Return valid if no vision model is configured
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||
const timeout =
|
||||
Math.max(
|
||||
1000,
|
||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||
) || 10000
|
||||
|
||||
// Stream the VLM response for useObject consumption
|
||||
const result = streamText({
|
||||
model,
|
||||
output: Output.object({ schema: ValidationResultSchema }),
|
||||
system: VALIDATION_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
image: imageData,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Please analyze this diagram for visual quality issues.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ output, totalUsage }) => {
|
||||
if (countsQuota && totalUsage) {
|
||||
recordTokenUsage(
|
||||
userId,
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0),
|
||||
)
|
||||
}
|
||||
if (sessionId && output) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
// Log with session context if available
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
console.error("[validate-diagram] Error:", errorMessage)
|
||||
|
||||
// On error, return valid to not block the user
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { streamText, tool } 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 { 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
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
// AWS Bedrock specific
|
||||
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,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
awsSessionToken,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Provider and model ID are required" },
|
||||
{ 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))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials based on provider
|
||||
if (provider === "bedrock") {
|
||||
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider === "vertexai") {
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
break
|
||||
}
|
||||
if (part.type === "finish") finishReason = part.finishReason
|
||||
}
|
||||
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)
|
||||
return NextResponse.json(
|
||||
{ valid: false, code, error: message },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+121
-294
@@ -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,204 +43,113 @@
|
||||
--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);
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-interactive-hover: var(--interactive-hover);
|
||||
--color-interactive-active: var(--interactive-active);
|
||||
--color-success: var(--success);
|
||||
--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;
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #14181d;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #14181d;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--primary: #14181d;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f1f3f5;
|
||||
--secondary-foreground: #14181d;
|
||||
--muted: #f1f3f5;
|
||||
--muted-foreground: #5d6672;
|
||||
--faint: #687180;
|
||||
--accent: #eef0f3;
|
||||
--accent-foreground: #14181d;
|
||||
--destructive: #c9372c;
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--border: #e4e7eb;
|
||||
--input: #dfe3e8;
|
||||
--ring: #14181d;
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
--marker: #ffd84d;
|
||||
--marker-soft: #fff4bf;
|
||||
--marker-ink: #6b5200;
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
|
||||
--chart-1: #3b6fd8;
|
||||
--chart-2: #1f9e86;
|
||||
--chart-3: #e8892c;
|
||||
--chart-4: #c2417a;
|
||||
--chart-5: #2a8fb8;
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.5 0.02 260);
|
||||
|
||||
--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;
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
--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;
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
|
||||
--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);
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
|
||||
--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);
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/*
|
||||
* 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;
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--card: #16304f;
|
||||
--card-foreground: #e9f0f6;
|
||||
--popover: #1b3a5d;
|
||||
--popover-foreground: #e9f0f6;
|
||||
--card: oklch(0.2 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--primary: #e9f0f6;
|
||||
--primary-foreground: #0c1a29;
|
||||
--secondary: #1d3b5e;
|
||||
--secondary-foreground: #e9f0f6;
|
||||
--muted: #1d3b5e;
|
||||
--muted-foreground: #a8bbce;
|
||||
--faint: #8aa0b6;
|
||||
--accent: #21426a;
|
||||
--accent-foreground: #e9f0f6;
|
||||
--destructive: #f07167;
|
||||
--popover: oklch(0.2 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--border: rgb(170 200 230 / 0.16);
|
||||
--input: rgb(170 200 230 / 0.24);
|
||||
--ring: #e9f0f6;
|
||||
--primary: oklch(0.7 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
|
||||
--marker: #f5b13d;
|
||||
--marker-soft: rgb(245 177 61 / 0.16);
|
||||
--marker-ink: #f7c46b;
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.9 0.01 260);
|
||||
|
||||
--chart-1: #7aa2ff;
|
||||
--chart-2: #4fd1b4;
|
||||
--chart-3: #f5b13d;
|
||||
--chart-4: #f08bb4;
|
||||
--chart-5: #5cc3ea;
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
|
||||
--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;
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
|
||||
--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);
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@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 +159,36 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/* Diagram pictures always render as on paper, also in dark mode
|
||||
(draw.io SVGs use light-dark() colors) */
|
||||
.sheet-light {
|
||||
color-scheme: light;
|
||||
}
|
||||
.shadow-float {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.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);
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -323,110 +207,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@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 {
|
||||
animation: none;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
|
||||
import "./globals.css"
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: [
|
||||
"AI diagram generator",
|
||||
"AWS architecture",
|
||||
"flowchart creator",
|
||||
"draw.io",
|
||||
"AI drawing tool",
|
||||
"technical diagrams",
|
||||
"diagram automation",
|
||||
"free diagram generator",
|
||||
"online diagram maker",
|
||||
],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Next AI Draw.io - AI-powered diagram creation tool",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Free, no login required.",
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Next AI Draw.io",
|
||||
applicationCategory: "DesignApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
description:
|
||||
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
)}
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Next AI Draw.io",
|
||||
short_name: "AIDraw.io",
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
start_url: getAssetUrl("/"),
|
||||
display: "standalone",
|
||||
background_color: "#f9fafb",
|
||||
theme_color: "#171d26",
|
||||
icons: [
|
||||
{
|
||||
src: getAssetUrl("/favicon-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: getAssetUrl("/favicon-512x512.png"),
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
|
||||
const drawioBaseUrl =
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||
useDiagram()
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [closeProtection, setCloseProtection] = useState(false)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (savedUi === "min" || savedUi === "sketch") {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
|
||||
if (savedDarkMode !== null) {
|
||||
// Use saved preference
|
||||
const isDark = savedDarkMode === "true"
|
||||
setDarkMode(isDark)
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
} else {
|
||||
// First visit: match browser preference
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches
|
||||
setDarkMode(prefersDark)
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
|
||||
const savedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
if (savedCloseProtection === "true") {
|
||||
setCloseProtection(true)
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||
document.documentElement.classList.toggle("dark", newValue)
|
||||
// Reset so onDrawioLoad fires again after remount
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
if (panel) {
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand()
|
||||
setIsChatVisible(true)
|
||||
} else {
|
||||
panel.collapse()
|
||||
setIsChatVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}, [])
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
useEffect(() => {
|
||||
if (!closeProtection) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [closeProtection])
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
id="main-panel-group"
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full"
|
||||
>
|
||||
{/* Draw.io Canvas */}
|
||||
<ResizablePanel
|
||||
id="drawio-panel"
|
||||
defaultSize={isMobile ? 50 : 67}
|
||||
minSize={20}
|
||||
>
|
||||
<div
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||
{isLoaded ? (
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={onDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
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"}`}>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={() => {
|
||||
const newUi =
|
||||
drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newUi)
|
||||
setDrawioUi(newUi)
|
||||
resetDrawioReady()
|
||||
}}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={toggleDarkMode}
|
||||
isMobile={isMobile}
|
||||
onCloseProtectionChange={setCloseProtection}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-8
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/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,
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => {
|
||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||
if (provider === "amazon-bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSectionHeaderProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModelSelectorSectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: ModelSelectorSectionHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { Cloud, FileText, GitBranch, Palette, Zap } from "lucide-react"
|
||||
|
||||
interface ExampleCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function ExampleCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
isNew,
|
||||
}: ExampleCardProps) {
|
||||
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">
|
||||
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,
|
||||
}: {
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
}) {
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.")
|
||||
|
||||
try {
|
||||
const response = await fetch("/example.png")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading example image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReplicateArchitecture = async () => {
|
||||
setInput("Replicate this in aws style")
|
||||
|
||||
try {
|
||||
const response = await fetch("/architecture.png")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading architecture image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePdfExample = async () => {
|
||||
setInput("Summarize this paper as a diagram")
|
||||
|
||||
try {
|
||||
const response = await fetch("/chain-of-thought.txt")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "chain-of-thought.txt", {
|
||||
type: "text/plain",
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading text file:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
Create diagrams with AI
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
Describe what you want to create or upload an image to
|
||||
replicate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Examples grid */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
Quick Examples
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<ExampleCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title="Paper to Diagram"
|
||||
description="Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more"
|
||||
onClick={handlePdfExample}
|
||||
isNew
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Zap className="w-4 h-4 text-primary" />}
|
||||
title="Animated Diagram"
|
||||
description="Draw a transformer architecture with animated connectors"
|
||||
onClick={() => {
|
||||
setInput(
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
)
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Cloud className="w-4 h-4 text-primary" />}
|
||||
title="AWS Architecture"
|
||||
description="Create a cloud architecture diagram with AWS icons"
|
||||
onClick={handleReplicateArchitecture}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<GitBranch className="w-4 h-4 text-primary" />}
|
||||
title="Replicate Flowchart"
|
||||
description="Upload and replicate an existing flowchart"
|
||||
onClick={handleReplicateFlowchart}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Palette className="w-4 h-4 text-primary" />}
|
||||
title="Creative Drawing"
|
||||
description="Draw something fun and creative"
|
||||
onClick={() => {
|
||||
setInput("Draw a cat for me")
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
|
||||
Examples are cached for instant response
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_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,
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
|
||||
const availableSlots = MAX_FILES - existingCount
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
errors.push(`Maximum ${MAX_FILES} files allowed`)
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (validFiles.length >= availableSlots) {
|
||||
errors.push(`Only ${availableSlots} more file(s) allowed`)
|
||||
break
|
||||
}
|
||||
if (!isValidFileType(file)) {
|
||||
errors.push(`"${file.name}" is not a supported file type`)
|
||||
continue
|
||||
}
|
||||
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
|
||||
const isExtractedFile = isPdfFile(file) || isTextFile(file)
|
||||
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
|
||||
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
|
||||
errors.push(
|
||||
`"${file.name}" is ${formatFileSize(file.size)} (exceeds ${maxSizeMB}MB)`,
|
||||
)
|
||||
} else {
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[]) {
|
||||
if (errors.length === 0) return
|
||||
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
)
|
||||
} else {
|
||||
showErrorToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{errors.length} files rejected:
|
||||
</span>
|
||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||
{errors.slice(0, 3).map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li>...and {errors.length - 3} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onClearChat: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
pdfData?: Map<
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
showHistory?: boolean
|
||||
onToggleHistory?: (show: boolean) => void
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onClearChat,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
sessionId,
|
||||
error = null,
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
|
||||
// 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])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
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,
|
||||
)
|
||||
showValidationErrors(errors)
|
||||
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)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
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,
|
||||
)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
onClearChat()
|
||||
setShowClearDialog(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 previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
pdfData={pdfData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input container */}
|
||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Describe your diagram or upload a file..."
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
||||
{/* Left actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
tooltipContent="Clear conversation"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ResetWarningModal
|
||||
open={showClearDialog}
|
||||
onOpenChange={setShowClearDialog}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={onToggleHistory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleHistory(true)}
|
||||
disabled={isDisabled || diagramHistory.length === 0}
|
||||
tooltipContent="Diagram history"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
disabled={isDisabled}
|
||||
tooltipContent="Save diagram"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
tooltipContent="Upload file (image, PDF, text)"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={
|
||||
isDisabled ? "Sending..." : "Send message"
|
||||
}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
export type ValidationStatus =
|
||||
| "idle"
|
||||
| "capturing"
|
||||
| "validating"
|
||||
| "success"
|
||||
| "success_with_warnings"
|
||||
| "failed"
|
||||
| "error"
|
||||
| "skipped"
|
||||
|
||||
export interface ValidationState {
|
||||
status: ValidationStatus
|
||||
attempt?: number
|
||||
maxAttempts?: number
|
||||
result?: ValidationResult
|
||||
error?: string
|
||||
imageData?: string // Base64 PNG data URL
|
||||
}
|
||||
|
||||
interface ValidationCardProps {
|
||||
state: ValidationState
|
||||
onImproveWithSuggestions?: (feedback: string) => void
|
||||
}
|
||||
|
||||
export function ValidationCard({
|
||||
state,
|
||||
onImproveWithSuggestions,
|
||||
}: ValidationCardProps) {
|
||||
const dict = useDictionary()
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
state.status === "validating" || state.status === "failed",
|
||||
)
|
||||
const [hasRequestedImprovement, setHasRequestedImprovement] =
|
||||
useState(false)
|
||||
|
||||
// Generate improvement feedback from validation result
|
||||
const generateImprovementFeedback = (): string => {
|
||||
if (!state.result) return ""
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
"Please improve the diagram based on the following visual analysis feedback:",
|
||||
)
|
||||
lines.push("")
|
||||
|
||||
if (state.result.issues.length > 0) {
|
||||
lines.push("Issues to address:")
|
||||
for (const issue of state.result.issues) {
|
||||
lines.push(
|
||||
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (state.result.suggestions.length > 0) {
|
||||
lines.push("Suggestions for improvement:")
|
||||
for (const suggestion of state.result.suggestions) {
|
||||
lines.push(` - ${suggestion}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
lines.push("Regenerate the diagram with these improvements applied.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const handleImproveClick = () => {
|
||||
if (
|
||||
!onImproveWithSuggestions ||
|
||||
!state.result ||
|
||||
hasRequestedImprovement
|
||||
)
|
||||
return
|
||||
setHasRequestedImprovement(true)
|
||||
const feedback = generateImprovementFeedback()
|
||||
onImproveWithSuggestions(feedback)
|
||||
}
|
||||
|
||||
// Check if we should show the improve button
|
||||
const showImproveButton =
|
||||
onImproveWithSuggestions &&
|
||||
state.result &&
|
||||
(state.status === "success" ||
|
||||
state.status === "success_with_warnings" ||
|
||||
state.status === "skipped") &&
|
||||
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
switch (state.status) {
|
||||
case "capturing":
|
||||
return {
|
||||
label: dict.validation.capturing,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "validating":
|
||||
return {
|
||||
label: state.attempt
|
||||
? dict.validation.validatingWithAttempt
|
||||
.replace("{attempt}", String(state.attempt))
|
||||
.replace("{max}", String(state.maxAttempts || 3))
|
||||
: dict.validation.validating,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "success":
|
||||
return {
|
||||
label: dict.validation.valid,
|
||||
color: "text-green-600 bg-green-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "success_with_warnings":
|
||||
return {
|
||||
label: dict.validation.validWithWarnings,
|
||||
color: "text-amber-600 bg-amber-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "failed":
|
||||
return {
|
||||
label: dict.validation.issuesFound,
|
||||
color: "text-yellow-600 bg-yellow-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
label: dict.validation.error,
|
||||
color: "text-red-600 bg-red-50",
|
||||
icon: <X className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "skipped":
|
||||
return {
|
||||
label: dict.validation.skipped,
|
||||
color: "text-gray-600 bg-gray-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const statusDisplay = getStatusDisplay()
|
||||
if (!statusDisplay || state.status === "idle") return null
|
||||
|
||||
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">
|
||||
<Eye
|
||||
className="w-3.5 h-3.5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{dict.validation.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
<span className="ml-1">{statusDisplay.label}</span>
|
||||
</span>
|
||||
{(state.result || state.error) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation details when expanded */}
|
||||
{isExpanded && (state.result || state.imageData) && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
|
||||
{/* Captured image */}
|
||||
{state.imageData && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
|
||||
<ImageIcon
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.capturedScreenshot}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
|
||||
<Image
|
||||
src={state.imageData}
|
||||
alt="Captured diagram for validation"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-auto max-h-48 object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{state.result && state.result.issues.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.issuesFoundLabel}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.result.issues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-xs px-3 py-2 rounded-lg border ${
|
||||
issue.severity === "critical"
|
||||
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
|
||||
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium uppercase text-[10px] mr-2">
|
||||
[{issue.type}]
|
||||
</span>
|
||||
{issue.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{state.result && state.result.suggestions.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.suggestions}
|
||||
</div>
|
||||
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
|
||||
{state.result.suggestions.map(
|
||||
(suggestion, index) => (
|
||||
<li key={index}>{suggestion}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valid result message */}
|
||||
{state.result?.valid &&
|
||||
state.result.issues.length === 0 && (
|
||||
<div className="text-xs text-green-600 dark:text-green-400">
|
||||
{dict.validation.passedValidation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
|
||||
{showImproveButton && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
|
||||
{hasRequestedImprovement ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
{dict.validation.improvementRequested}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImproveClick}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.improveWithSuggestions}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
{dict.validation.regenerateWithFeedback}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details when expanded */}
|
||||
{isExpanded && state.error && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
|
||||
<div className="text-xs text-red-600">{state.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
|
||||
export type { DiagramOperation }
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="No" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Truncated (Error Test)": `<mxCell id="2" value="This cell is truncated" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
|
||||
"HTML Escape + Cell Truncate": `<mxCell id="2" value="<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/> (No explanation of reasoning)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/> (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/> (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/> (vs 84% human)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/> Works on 3-4 flips with CoT<br/>• Standard prompting fails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="<b>Emergent Ability of Scale</b><br/>• Small models (<10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: "show your work"<br/>• Emergent capability of large models<br/>• No specialized architecture needed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="3" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="5" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="6" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;" edge="1" parent="1" source="7" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="7" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;" edge="1" parent="1" source="7" target="11">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="9" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="10" target="13">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="11" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="12" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="13" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="14" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
}
|
||||
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
onShowQuotaToast?: () => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
onShowQuotaToast,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const dict = useDictionary()
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
const [devChunkSize, setDevChunkSize] = useState(10)
|
||||
const devStopRef = useRef(false)
|
||||
const devXmlInitializedRef = useRef(false)
|
||||
|
||||
// Restore dev XML from localStorage on mount (after hydration)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("dev-xml-simulator")
|
||||
if (saved) setDevXml(saved)
|
||||
devXmlInitializedRef.current = true
|
||||
}, [])
|
||||
|
||||
// Save dev XML to localStorage (only after initial load)
|
||||
useEffect(() => {
|
||||
if (devXmlInitializedRef.current) {
|
||||
localStorage.setItem("dev-xml-simulator", devXml)
|
||||
}
|
||||
}, [devXml])
|
||||
|
||||
const handleDevSimulate = async () => {
|
||||
if (!devXml.trim() || isSimulating) return
|
||||
|
||||
setIsSimulating(true)
|
||||
devStopRef.current = false
|
||||
const toolCallId = `dev-sim-${Date.now()}`
|
||||
const xml = devXml.trim()
|
||||
|
||||
// Add user message and initial assistant message with empty XML
|
||||
const userMsg = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.dev.simulatingMessage,
|
||||
},
|
||||
],
|
||||
}
|
||||
const assistantMsg = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "tool-display_diagram" as const,
|
||||
toolCallId,
|
||||
state: "input-streaming" as const,
|
||||
input: { xml: "" },
|
||||
},
|
||||
],
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
|
||||
|
||||
// Stream characters progressively
|
||||
for (let i = 0; i < xml.length; i += devChunkSize) {
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = xml.slice(0, i + devChunkSize)
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].input = { xml: chunk }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, devIntervalMs))
|
||||
}
|
||||
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize: set state to output-available
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = dict.dev.successMessage
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const prepared = prepareNewDiagram(xml)
|
||||
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
{dict.dev.title}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.preset}
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setDevXml(DEV_XML_PRESETS[e.target.value])
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs p-1 border rounded bg-background"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
{dict.dev.selectPreset}
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
{dict.dev.clear}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder={dict.dev.placeholder}
|
||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.interval}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
value={devIntervalMs}
|
||||
onChange={(e) =>
|
||||
setDevIntervalMs(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 h-1 accent-orange-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-12">
|
||||
{devIntervalMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.chars}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={devChunkSize}
|
||||
onChange={(e) =>
|
||||
setDevChunkSize(
|
||||
Math.max(1, Number(e.target.value)),
|
||||
)
|
||||
}
|
||||
className="w-14 text-xs p-1 border rounded bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDevSimulate}
|
||||
disabled={isSimulating || !devXml.trim()}
|
||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSimulating
|
||||
? dict.dev.streaming
|
||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
devStopRef.current = true
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{dict.dev.stop}
|
||||
</button>
|
||||
)}
|
||||
{onShowQuotaToast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowQuotaToast}
|
||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
{dict.dev.testQuotaToast}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||
import { FileCode, FileText, 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"
|
||||
|
||||
function formatCharCount(count: number): string {
|
||||
@@ -20,24 +19,17 @@ interface FilePreviewListProps {
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<
|
||||
string,
|
||||
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
onRemoveUrl?: (url: string) => void
|
||||
}
|
||||
|
||||
export function FilePreviewList({
|
||||
files,
|
||||
onRemoveFile,
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onRemoveUrl,
|
||||
}: FilePreviewListProps) {
|
||||
const dict = useDictionary()
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
||||
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
||||
|
||||
// Create and cleanup object URLs when files change
|
||||
useEffect(() => {
|
||||
const currentUrls = imageUrlsRef.current
|
||||
@@ -54,6 +46,7 @@ export function FilePreviewList({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Revoke URLs for files that are no longer in the list
|
||||
currentUrls.forEach((url, file) => {
|
||||
if (!newUrls.has(file)) {
|
||||
@@ -64,6 +57,7 @@ export function FilePreviewList({
|
||||
imageUrlsRef.current = newUrls
|
||||
setImageUrls(newUrls)
|
||||
}, [files])
|
||||
|
||||
// Cleanup all URLs on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -74,6 +68,7 @@ export function FilePreviewList({
|
||||
imageUrlsRef.current = new Map()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Clear selected image if its URL was revoked
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -84,7 +79,7 @@ export function FilePreviewList({
|
||||
}
|
||||
}, [imageUrls, selectedImage])
|
||||
|
||||
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -131,14 +126,14 @@ export function FilePreviewList({
|
||||
</span>
|
||||
{pdfInfo?.isExtracting ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dict.file.reading}
|
||||
Reading...
|
||||
</span>
|
||||
) : pdfInfo?.charCount ? (
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
{formatCharCount(
|
||||
pdfInfo.charCount,
|
||||
)}{" "}
|
||||
{dict.file.chars}
|
||||
chars
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -152,67 +147,15 @@ export function FilePreviewList({
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(file)}
|
||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={dict.file.removeFile}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* URL previews */}
|
||||
{urlData && urlData.size > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(urlData.entries()).map(
|
||||
([url, data], index) => (
|
||||
<div
|
||||
key={url + index}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
|
||||
<div className="flex flex-col items-center justify-center h-full p-1">
|
||||
{data.isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dict.file.reading}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="h-6 w-6 text-blue-500 mb-1" />
|
||||
<span className="text-xs text-center truncate w-full px-1">
|
||||
{data.title.length > 10
|
||||
? `${data.title.slice(0, 7)}...`
|
||||
: data.title}
|
||||
</span>
|
||||
{data.charCount && (
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
{formatCharCount(
|
||||
data.charCount,
|
||||
)}{" "}
|
||||
{dict.file.chars}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onRemoveUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUrl(url)}
|
||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={dict.file.removeFile}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Modal/Lightbox */}
|
||||
{selectedImage && (
|
||||
<div
|
||||
@@ -222,7 +165,7 @@ export function FilePreviewList({
|
||||
<button
|
||||
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
aria-label={dict.common.close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"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"
|
||||
|
||||
interface HistoryDialogProps {
|
||||
showHistory: boolean
|
||||
onToggleHistory: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function HistoryDialog({
|
||||
showHistory,
|
||||
onToggleHistory,
|
||||
}: HistoryDialogProps) {
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIndex(null)
|
||||
onToggleHistory(false)
|
||||
}
|
||||
|
||||
const handleConfirmRestore = () => {
|
||||
if (selectedIndex !== null) {
|
||||
// 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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Diagram History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Here saved each diagram before AI modification.
|
||||
<br />
|
||||
Click on a diagram to restore it
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{diagramHistory.length === 0 ? (
|
||||
<div className="text-center p-4 text-gray-500">
|
||||
No history available yet. Send messages to create
|
||||
diagram history.
|
||||
</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={`Diagram 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">
|
||||
Version {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIndex !== null ? (
|
||||
<>
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
Restore to Version {selectedIndex + 1}?
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedIndex(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRestore}>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -1,470 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSectionHeader,
|
||||
ModelSelectorSeparator,
|
||||
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 { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure?: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
for (const model of models) {
|
||||
// For server models, strip "Server · " prefix for cleaner grouping
|
||||
const key =
|
||||
model.source === "server"
|
||||
? model.providerLabel.replace(/^Server · /, "")
|
||||
: model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
} else {
|
||||
groups.set(key, { provider: model.provider, models: [model] })
|
||||
}
|
||||
}
|
||||
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,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
const dict = useDictionary()
|
||||
const [open, setOpen] = useState(false)
|
||||
// Filter models based on showUnvalidatedModels setting
|
||||
const displayModels = useMemo(() => {
|
||||
if (showUnvalidatedModels) {
|
||||
return models
|
||||
}
|
||||
return models.filter((m) => m.validated === true)
|
||||
}, [models, showUnvalidatedModels])
|
||||
|
||||
// Separate server and user models
|
||||
const serverModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source === "server"),
|
||||
[displayModels],
|
||||
)
|
||||
const userModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source !== "server"),
|
||||
[displayModels],
|
||||
)
|
||||
|
||||
// Group each category separately
|
||||
const groupedServerModels = useMemo(
|
||||
() => groupModelsByProvider(serverModels),
|
||||
[serverModels],
|
||||
)
|
||||
const groupedUserModels = useMemo(
|
||||
() => groupModelsByProvider(userModels),
|
||||
[userModels],
|
||||
)
|
||||
|
||||
// Find selected model for display
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId),
|
||||
[models, selectedModelId],
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
} else {
|
||||
onSelect(value)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const tooltipContent = selectedModel
|
||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showLabel, setShowLabel] = useState(true)
|
||||
|
||||
// Threshold (px) under which we hide the label (tweak as needed)
|
||||
const HIDE_THRESHOLD = 240
|
||||
const SHOW_THRESHOLD = 260
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current
|
||||
if (!el) return
|
||||
|
||||
const target = el.parentElement ?? el
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width
|
||||
setShowLabel((prev) => {
|
||||
// if currently showing and width dropped below hide threshold -> hide
|
||||
if (prev && width <= HIDE_THRESHOLD) return false
|
||||
// if currently hidden and width rose above show threshold -> show
|
||||
if (!prev && width >= SHOW_THRESHOLD) return true
|
||||
// otherwise keep previous state (hysteresis)
|
||||
return prev
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(target)
|
||||
|
||||
const initialWidth = target.getBoundingClientRect().width
|
||||
setShowLabel(initialWidth >= SHOW_THRESHOLD)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
variant="ghost"
|
||||
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",
|
||||
!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" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? shortModelName(selectedModel.modelId)
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
|
||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||
<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>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { Coffee, Settings, X } from "lucide-react"
|
||||
import { Coffee, X } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import type React from "react"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
interface QuotaLimitToastProps {
|
||||
type?: "request" | "token"
|
||||
used: number
|
||||
limit: number
|
||||
onDismiss: () => void
|
||||
onConfigModel?: () => void
|
||||
}
|
||||
|
||||
export function QuotaLimitToast({
|
||||
@@ -19,26 +17,10 @@ export function QuotaLimitToast({
|
||||
used,
|
||||
limit,
|
||||
onDismiss,
|
||||
onConfigModel,
|
||||
}: 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()
|
||||
@@ -62,6 +44,7 @@ export function QuotaLimitToast({
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Title row with icon */}
|
||||
<div className="flex items-center gap-2.5 mb-3 pr-6">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
|
||||
@@ -72,75 +55,60 @@ export function QuotaLimitToast({
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{isTokenLimit
|
||||
? dict.quota.tokenLimit
|
||||
: dict.quota.dailyLimit}
|
||||
? "Daily Token Limit Reached"
|
||||
: "Daily Quota Reached"}
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
|
||||
{formatMessage(dict.quota.usedOf, {
|
||||
used: formatNumber(used),
|
||||
limit: formatNumber(limit),
|
||||
})}
|
||||
{isTokenLimit
|
||||
? `${formatNumber(used)}/${formatNumber(limit)} tokens`
|
||||
: `${used}/${limit}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||
<p>{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
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: tipHtml,
|
||||
}}
|
||||
/>
|
||||
<p>{dict.quota.reset}</p>
|
||||
</div>{" "}
|
||||
<p>
|
||||
Oops — you've reached the daily{" "}
|
||||
{isTokenLimit ? "token" : "API"} limit for this demo! As an
|
||||
indie developer covering all the API costs myself, I have to
|
||||
set these limits to keep things sustainable.{" "}
|
||||
<Link
|
||||
href="/about"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-amber-600 font-medium hover:text-amber-700 hover:underline"
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tip:</strong> You can use your own API key (click
|
||||
the Settings icon) or self-host the project to bypass these
|
||||
limits.
|
||||
</p>
|
||||
<p>Your limit resets tomorrow. Thanks for understanding!</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onConfigModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfigModel()
|
||||
onDismiss()
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{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 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
Self-host
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
Sponsor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
interface ResetWarningModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function ResetWarningModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onClear,
|
||||
}: ResetWarningModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Everything?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will clear the current conversation and reset the
|
||||
diagram. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onClear}>
|
||||
Clear Everything
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+49
-81
@@ -5,15 +5,30 @@ import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
|
||||
const FORMAT_OPTIONS: {
|
||||
value: ExportFormat
|
||||
label: string
|
||||
extension: string
|
||||
}[] = [
|
||||
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
|
||||
{ value: "png", label: "PNG Image", extension: ".png" },
|
||||
{ value: "svg", label: "SVG Image", extension: ".svg" },
|
||||
]
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
@@ -28,7 +43,6 @@ export function SaveDialog({
|
||||
onSave,
|
||||
defaultFilename,
|
||||
}: SaveDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [filename, setFilename] = useState(defaultFilename)
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
||||
|
||||
@@ -51,93 +65,49 @@ export function SaveDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{
|
||||
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>
|
||||
{dict.save.description}
|
||||
</DialogDescription>
|
||||
<DialogTitle>Save Diagram</DialogTitle>
|
||||
</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">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"
|
||||
>
|
||||
{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"
|
||||
<label className="text-sm font-medium">Filename</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={dict.save.filenamePlaceholder}
|
||||
placeholder="Enter filename"
|
||||
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>
|
||||
@@ -148,11 +118,9 @@ export function SaveDialog({
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{dict.save.exportButton}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
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"
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
|
||||
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
|
||||
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
|
||||
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)
|
||||
if (stored === null) return null
|
||||
return stored === "true"
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseProtectionChange,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
}: SettingsDialogProps) {
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [closeProtection, setCloseProtection] = useState(true)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [provider, setProvider] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch if not cached in localStorage
|
||||
if (getStoredAccessCodeRequired() !== null) return
|
||||
|
||||
fetch("/api/config")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const required = data?.accessCodeRequired === true
|
||||
localStorage.setItem(
|
||||
STORAGE_ACCESS_CODE_REQUIRED_KEY,
|
||||
String(required),
|
||||
)
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't cache on error - allow retry on next mount
|
||||
setAccessCodeRequired(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const storedCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const storedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
|
||||
// Load AI provider settings
|
||||
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
|
||||
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
|
||||
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
|
||||
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
|
||||
|
||||
setError("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessCodeRequired) return
|
||||
|
||||
setError("")
|
||||
setIsVerifying(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/verify-access-code", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.message || "Invalid access code")
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setError("Failed to verify access code")
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your application settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{accessCodeRequired && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access-code">Access Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter access code"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
>
|
||||
{isVerifying ? "..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Required to use this application.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-[0.8rem] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider Settings</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Use your own API key to bypass usage limits. Your
|
||||
key is stored locally in your browser and is never
|
||||
stored on the server.
|
||||
</p>
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-provider">Provider</Label>
|
||||
<Select
|
||||
value={provider || "default"}
|
||||
onValueChange={(value) => {
|
||||
const actualValue =
|
||||
value === "default" ? "" : value
|
||||
setProvider(actualValue)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
actualValue,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ai-provider">
|
||||
<SelectValue placeholder="Use Server Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
Use Server Default
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">
|
||||
OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="anthropic">
|
||||
Anthropic
|
||||
</SelectItem>
|
||||
<SelectItem value="google">
|
||||
Google
|
||||
</SelectItem>
|
||||
<SelectItem value="azure">
|
||||
Azure OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="openrouter">
|
||||
OpenRouter
|
||||
</SelectItem>
|
||||
<SelectItem value="deepseek">
|
||||
DeepSeek
|
||||
</SelectItem>
|
||||
<SelectItem value="siliconflow">
|
||||
SiliconFlow
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{provider && provider !== "default" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
Model ID
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={modelId}
|
||||
onChange={(e) => {
|
||||
setModelId(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "openai"
|
||||
? "e.g., gpt-4o"
|
||||
: provider === "anthropic"
|
||||
? "e.g., claude-sonnet-4-5"
|
||||
: provider === "google"
|
||||
? "e.g., gemini-2.0-flash-exp"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "e.g., deepseek-chat"
|
||||
: "Model ID"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder="Your API key"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Overrides{" "}
|
||||
{provider === "openai"
|
||||
? "OPENAI_API_KEY"
|
||||
: provider === "anthropic"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: provider === "google"
|
||||
? "GOOGLE_GENERATIVE_AI_API_KEY"
|
||||
: provider === "azure"
|
||||
? "AZURE_API_KEY"
|
||||
: provider ===
|
||||
"openrouter"
|
||||
? "OPENROUTER_API_KEY"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "DEEPSEEK_API_KEY"
|
||||
: provider ===
|
||||
"siliconflow"
|
||||
? "SILICONFLOW_API_KEY"
|
||||
: "server API key"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
Base URL (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "anthropic"
|
||||
? "https://api.anthropic.com/v1"
|
||||
: provider === "siliconflow"
|
||||
? "https://api.siliconflow.com/v1"
|
||||
: "Custom endpoint URL"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
)
|
||||
setProvider("")
|
||||
setBaseUrl("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
}}
|
||||
>
|
||||
Clear Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="theme-toggle">Theme</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Dark/Light mode for interface and DrawIO canvas.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="drawio-ui">DrawIO Style</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Canvas style:{" "}
|
||||
{drawioUi === "min" ? "Minimal" : "Sketch"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleDrawioUi}
|
||||
>
|
||||
Switch to{" "}
|
||||
{drawioUi === "min" ? "Sketch" : "Minimal"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="close-protection">
|
||||
Close Protection
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Show confirmation when leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={(checked) => {
|
||||
setCloseProtection(checked)
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
checked.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={(e) => {
|
||||
// Ensure hover updates selection for visual feedback
|
||||
const item = e.currentTarget
|
||||
item.setAttribute("data-selected", "true")
|
||||
// Deselect siblings
|
||||
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
|
||||
siblings?.forEach((sibling) => {
|
||||
if (sibling !== item) {
|
||||
sibling.setAttribute("data-selected", "false")
|
||||
}
|
||||
})
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -38,10 +38,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,32 +57,13 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
// Base styles
|
||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className={cn(
|
||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
||||
"text-muted-foreground/60 hover:text-foreground",
|
||||
"hover:bg-interactive-hover",
|
||||
"transition-all duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"disabled:pointer-events-none",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
||||
)}>
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -124,10 +102,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-xl font-semibold tracking-tight leading-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -140,10 +115,7 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground leading-relaxed",
|
||||
className
|
||||
)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+3
-24
@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
// Base styles
|
||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
||||
"border border-border-subtle bg-surface-1",
|
||||
"text-sm text-foreground",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground/60",
|
||||
// Selection
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// Transitions
|
||||
"transition-all duration-150 ease-out",
|
||||
// Hover state
|
||||
"hover:border-border-default",
|
||||
// Focus state - refined ring
|
||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
||||
// File input
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
||||
"file:bg-transparent file:text-sm file:font-medium",
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Invalid state
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"dark:aria-invalid:ring-destructive/40",
|
||||
// Dark mode background
|
||||
"dark:bg-surface-1",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -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 }
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Link, Loader2 } from "lucide-react"
|
||||
import { 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 { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
interface UrlInputDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (url: string) => void
|
||||
isExtracting: boolean
|
||||
}
|
||||
|
||||
export function UrlInputDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isExtracting,
|
||||
}: UrlInputDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [url, setUrl] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError("")
|
||||
|
||||
if (!url.trim()) {
|
||||
setError(dict.url.enterUrl)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
setError(dict.url.invalidFormat)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(url.trim())
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !isExtracting) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.url.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.url.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value)
|
||||
setError("")
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="https://example.com/article"
|
||||
disabled={isExtracting}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isExtracting}
|
||||
>
|
||||
{dict.url.Cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isExtracting || !url.trim()}
|
||||
>
|
||||
{isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{dict.url.Extracting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
{dict.url.extract}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+132
-313
@@ -1,216 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type {
|
||||
DrawioExportEvent,
|
||||
DrawioFrameHandle,
|
||||
} from "@/components/canvas/drawio-frame"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { 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
|
||||
}
|
||||
import { extractDiagramXML, 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 }[]
|
||||
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,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
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
|
||||
}
|
||||
|
||||
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 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)
|
||||
// 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 drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
||||
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 = () => {
|
||||
// console.log("[DiagramContext] Resetting DrawIO ready state")
|
||||
hasCalledOnLoadRef.current = false
|
||||
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)
|
||||
}
|
||||
// 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 })
|
||||
|
||||
// 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
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
return null
|
||||
}, [requestTaggedExport])
|
||||
|
||||
// Plain SVG (no embedded diagram data) for version cards
|
||||
const getVersionSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
const svgData = await requestTaggedExport("version", "svg", 4000)
|
||||
return svgData?.startsWith("data:image/svg") ? svgData : 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
|
||||
|
||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
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,99 +108,65 @@ 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)
|
||||
return
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
if (format === "png" || format === "svg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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
|
||||
if (expectHistoryExportRef.current) {
|
||||
setDiagramHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
])
|
||||
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 = (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready")
|
||||
@@ -328,88 +174,73 @@ 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"
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
// Save to localStorage when user manually saves
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else {
|
||||
// SVG format
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, { duration: 2500 })
|
||||
}
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
// 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)
|
||||
@@ -419,7 +250,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
sessionId?: string,
|
||||
) => {
|
||||
try {
|
||||
await fetch(getApiEndpoint("/api/log-save"), {
|
||||
await fetch("/api/log-save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, format, sessionId }),
|
||||
@@ -429,35 +260,23 @@ 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,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
handleExportWithoutHistory,
|
||||
exportResolversRef,
|
||||
resolverRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
getVersionSvg,
|
||||
captureValidationPng,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
setCommitHandler,
|
||||
startTurn,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+6
-10
@@ -1,16 +1,12 @@
|
||||
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
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# Next AI Draw.io
|
||||
|
||||
<div align="center">
|
||||
|
||||
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
||||
|
||||
[English](../README.md) | 中文 | [日本語](./README_JA.md)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/sponsors/DayuanJiang)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
</div>
|
||||
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## 目录
|
||||
- [Next AI Draw.io](#next-ai-drawio)
|
||||
- [目录](#目录)
|
||||
- [示例](#示例)
|
||||
- [功能特性](#功能特性)
|
||||
- [快速开始](#快速开始)
|
||||
- [在线试用](#在线试用)
|
||||
- [使用Docker运行(推荐)](#使用docker运行推荐)
|
||||
- [安装](#安装)
|
||||
- [部署](#部署)
|
||||
- [多提供商支持](#多提供商支持)
|
||||
- [工作原理](#工作原理)
|
||||
- [项目结构](#项目结构)
|
||||
- [支持与联系](#支持与联系)
|
||||
- [Star历史](#star历史)
|
||||
|
||||
## 示例
|
||||
|
||||
以下是一些示例提示词及其生成的图表:
|
||||
|
||||
<div align="center">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>动画Transformer连接器</strong><br />
|
||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||
<img src="../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWS架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫咪素描</strong><br />
|
||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||
<img src="../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
||||
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
|
||||
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
|
||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 在线试用
|
||||
|
||||
无需安装!直接在我们的演示站点试用:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> 注意:由于访问量较大,演示站点目前使用 minimax-m2 模型。如需获得最佳效果,建议使用 Claude Sonnet 4.5 或 Claude Opus 4.5 自行部署。
|
||||
|
||||
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
||||
|
||||
### 使用Docker运行(推荐)
|
||||
|
||||
如果您只想在本地运行,最好的方式是使用Docker。
|
||||
|
||||
首先,如果您还没有安装Docker,请先安装:[获取Docker](https://docs.docker.com/get-docker/)
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e AI_PROVIDER=openai \
|
||||
-e AI_MODEL=gpt-4o \
|
||||
-e OPENAI_API_KEY=your_api_key \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
或者使用 env 文件:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
# 编辑 .env 填写您的配置
|
||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
|
||||
|
||||
请根据您首选的AI提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
|
||||
|
||||
> **离线部署:** 如果 `embed.diagrams.net` 被屏蔽,请参阅 [离线部署指南](./offline-deployment.md) 了解配置选项。
|
||||
|
||||
### 安装
|
||||
|
||||
1. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 配置您的AI提供商:
|
||||
|
||||
在根目录创建 `.env.local` 文件:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
编辑 `.env.local` 并配置您选择的提供商:
|
||||
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||
- 添加您的提供商所需的API密钥
|
||||
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
|
||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
||||
|
||||
4. 运行开发服务器:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
|
||||
## 部署
|
||||
|
||||
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
|
||||
|
||||
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
|
||||
|
||||
或者您可以通过此按钮部署:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
||||
|
||||
|
||||
## 多提供商支持
|
||||
|
||||
- AWS Bedrock(默认)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
|
||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
||||
|
||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||
|
||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
|
||||
|
||||
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
|
||||
|
||||
## 工作原理
|
||||
|
||||
本应用使用以下技术:
|
||||
|
||||
- **Next.js**:用于前端框架和路由
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
|
||||
- **react-drawio**:用于图表表示和操作
|
||||
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
app/ # Next.js App Router
|
||||
api/chat/ # 带AI工具的聊天API端点
|
||||
page.tsx # 带DrawIO嵌入的主页面
|
||||
components/ # React组件
|
||||
chat-panel.tsx # 带图表控制的聊天界面
|
||||
chat-input.tsx # 带文件上传的用户输入组件
|
||||
history-dialog.tsx # 图表版本历史查看器
|
||||
ui/ # UI组件(按钮、卡片等)
|
||||
contexts/ # React上下文提供者
|
||||
diagram-context.tsx # 全局图表状态管理
|
||||
lib/ # 工具函数和辅助程序
|
||||
ai-providers.ts # 多提供商AI配置
|
||||
utils.ts # XML处理和转换工具
|
||||
public/ # 静态资源包括示例图片
|
||||
```
|
||||
|
||||
## 支持与联系
|
||||
|
||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||
|
||||
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
|
||||
|
||||
- 邮箱:me[at]jiang.jp
|
||||
|
||||
## Star历史
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
---
|
||||
@@ -0,0 +1,250 @@
|
||||
# Next AI Draw.io
|
||||
|
||||
<div align="center">
|
||||
|
||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||
|
||||
[English](../README.md) | [中文](./README_CN.md) | 日本語
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/sponsors/DayuanJiang)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
</div>
|
||||
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## 目次
|
||||
- [Next AI Draw.io](#next-ai-drawio)
|
||||
- [目次](#目次)
|
||||
- [例](#例)
|
||||
- [機能](#機能)
|
||||
- [はじめに](#はじめに)
|
||||
- [オンラインで試す](#オンラインで試す)
|
||||
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
||||
- [インストール](#インストール)
|
||||
- [デプロイ](#デプロイ)
|
||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||
- [仕組み](#仕組み)
|
||||
- [プロジェクト構造](#プロジェクト構造)
|
||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||
- [スター履歴](#スター履歴)
|
||||
|
||||
## 例
|
||||
|
||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
||||
|
||||
<div align="center">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||
<img src="../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCPアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWSアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azureアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫のスケッチ</strong><br />
|
||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||
<img src="../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 機能
|
||||
|
||||
- **LLM搭載のダイアグラム作成**:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
|
||||
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
|
||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
## はじめに
|
||||
|
||||
### オンラインで試す
|
||||
|
||||
インストール不要!デモサイトで直接お試しください:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> 注意:アクセス数が多いため、デモサイトでは現在 minimax-m2 モデルを使用しています。最高の結果を得るには、Claude Sonnet 4.5 または Claude Opus 4.5 でのセルフホスティングをお勧めします。
|
||||
|
||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
|
||||
### Dockerで実行(推奨)
|
||||
|
||||
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
|
||||
|
||||
まず、Dockerをインストールしていない場合はインストールしてください:[Dockerを入手](https://docs.docker.com/get-docker/)
|
||||
|
||||
次に実行:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e AI_PROVIDER=openai \
|
||||
-e AI_MODEL=gpt-4o \
|
||||
-e OPENAI_API_KEY=your_api_key \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
または env ファイルを使用:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
# .env を編集して設定を入力
|
||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。
|
||||
|
||||
環境変数はお好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
|
||||
|
||||
> **オフラインデプロイ:** `embed.diagrams.net` がブロックされている場合は、[オフラインデプロイガイド](./offline-deployment.md) で設定オプションをご確認ください。
|
||||
|
||||
### インストール
|
||||
|
||||
1. リポジトリをクローン:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. 依存関係をインストール:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. AIプロバイダーを設定:
|
||||
|
||||
ルートディレクトリに`.env.local`ファイルを作成:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
`.env.local`を編集して選択したプロバイダーを設定:
|
||||
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- `AI_MODEL`を使用する特定のモデルに設定
|
||||
- プロバイダーに必要なAPIキーを追加
|
||||
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||
|
||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||
|
||||
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||
|
||||
4. 開発サーバーを起動:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
|
||||
|
||||
## デプロイ
|
||||
|
||||
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
|
||||
|
||||
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
||||
|
||||
または、このボタンでデプロイできます:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
||||
|
||||
|
||||
## マルチプロバイダーサポート
|
||||
|
||||
- AWS Bedrock(デフォルト)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
|
||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||
|
||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||
|
||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
||||
|
||||
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
|
||||
## 仕組み
|
||||
|
||||
本アプリケーションは以下の技術を使用しています:
|
||||
|
||||
- **Next.js**:フロントエンドフレームワークとルーティング
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
- **react-drawio**:ダイアグラムの表現と操作
|
||||
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
|
||||
## プロジェクト構造
|
||||
|
||||
```
|
||||
app/ # Next.js App Router
|
||||
api/chat/ # AIツール付きチャットAPIエンドポイント
|
||||
page.tsx # DrawIO埋め込み付きメインページ
|
||||
components/ # Reactコンポーネント
|
||||
chat-panel.tsx # ダイアグラム制御付きチャットインターフェース
|
||||
chat-input.tsx # ファイルアップロード付きユーザー入力コンポーネント
|
||||
history-dialog.tsx # ダイアグラムバージョン履歴ビューア
|
||||
ui/ # UIコンポーネント(ボタン、カードなど)
|
||||
contexts/ # Reactコンテキストプロバイダー
|
||||
diagram-context.tsx # グローバルダイアグラム状態管理
|
||||
lib/ # ユーティリティ関数とヘルパー
|
||||
ai-providers.ts # マルチプロバイダーAI設定
|
||||
utils.ts # XML処理と変換ユーティリティ
|
||||
public/ # サンプル画像を含む静的アセット
|
||||
```
|
||||
|
||||
## サポート&お問い合わせ
|
||||
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||
|
||||
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
|
||||
|
||||
- メール:me[at]jiang.jp
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
---
|
||||
@@ -0,0 +1,178 @@
|
||||
# AI Provider Configuration
|
||||
|
||||
This guide explains how to configure different AI model providers for next-ai-draw-io.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy `.env.example` to `.env.local`
|
||||
2. Set your API key for your chosen provider
|
||||
3. Set `AI_MODEL` to your desired model
|
||||
4. Run `npm run dev`
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```bash
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
||||
AI_MODEL=gemini-2.0-flash
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=your_api_key
|
||||
AI_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
Optional custom endpoint (for OpenAI-compatible services):
|
||||
|
||||
```bash
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### DeepSeek
|
||||
|
||||
```bash
|
||||
DEEPSEEK_API_KEY=your_api_key
|
||||
AI_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### SiliconFlow (OpenAI-compatible)
|
||||
|
||||
```bash
|
||||
SILICONFLOW_API_KEY=your_api_key
|
||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # example; use any SiliconFlow model id
|
||||
```
|
||||
|
||||
Optional custom endpoint (defaults to the recommended domain):
|
||||
|
||||
```bash
|
||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
|
||||
```
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
```bash
|
||||
AZURE_API_KEY=your_api_key
|
||||
AZURE_RESOURCE_NAME=your-resource-name # Required: your Azure resource name
|
||||
AI_MODEL=your-deployment-name
|
||||
```
|
||||
|
||||
Or use a custom endpoint instead of resource name:
|
||||
|
||||
```bash
|
||||
AZURE_API_KEY=your_api_key
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # Alternative to AZURE_RESOURCE_NAME
|
||||
AI_MODEL=your-deployment-name
|
||||
```
|
||||
|
||||
Optional reasoning configuration:
|
||||
|
||||
```bash
|
||||
AZURE_REASONING_EFFORT=low # Optional: low, medium, high
|
||||
AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
||||
```
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
```bash
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
```
|
||||
|
||||
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
```bash
|
||||
OPENROUTER_API_KEY=your_api_key
|
||||
AI_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Ollama (Local)
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=ollama
|
||||
AI_MODEL=llama3.2
|
||||
```
|
||||
|
||||
Optional custom URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
|
||||
```
|
||||
|
||||
## Model Capability Requirements
|
||||
|
||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
||||
|
||||
**Recommended models**:
|
||||
|
||||
- Claude Sonnet 4.5 / Opus 4.5
|
||||
|
||||
**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.
|
||||
|
||||
## Temperature Setting
|
||||
|
||||
You can optionally configure the temperature via environment variable:
|
||||
|
||||
```bash
|
||||
TEMPERATURE=0 # More deterministic output (recommended for diagrams)
|
||||
```
|
||||
|
||||
**Important**: Leave `TEMPERATURE` unset for models that don't support temperature settings, such as:
|
||||
- GPT-5.1 and other reasoning models
|
||||
- Some specialized models
|
||||
|
||||
When unset, the model uses its default behavior.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
|
||||
- **Budget-friendly**: DeepSeek offers competitive pricing
|
||||
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
|
||||
- **Flexibility**: OpenRouter provides access to many models through a single API
|
||||
@@ -1,80 +0,0 @@
|
||||
# 常见问题解答 (FAQ)
|
||||
|
||||
---
|
||||
|
||||
## 1. 无法导出 PDF
|
||||
|
||||
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
|
||||
|
||||
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
|
||||
|
||||
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
|
||||
|
||||
**相关 Issue**: #539, #125
|
||||
|
||||
---
|
||||
|
||||
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
|
||||
|
||||
**问题**: 内网环境提示"找不到 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 代码中,**运行时设置无效**!
|
||||
|
||||
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
|
||||
|
||||
**相关 Issue**: #295, #317
|
||||
|
||||
---
|
||||
|
||||
## 3. 自建模型只思考不画图
|
||||
|
||||
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
|
||||
|
||||
**可能原因**:
|
||||
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
|
||||
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
|
||||
|
||||
**解决方案**: 开启 tool calling,例如 vLLM:
|
||||
```bash
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen3-32B \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**相关 Issue**: #269, #75
|
||||
|
||||
---
|
||||
|
||||
## 4. 上传图片后提示"未提供图片"
|
||||
|
||||
**问题**: 上传图片后,系统显示"未提供图片"错误
|
||||
|
||||
**可能原因**:
|
||||
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
|
||||
|
||||
**解决方案**:
|
||||
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
||||
- 模型名带 `vision` 或 `vl` 的支持图片
|
||||
- 更新到最新版本(v0.4.9+)
|
||||
|
||||
**相关 Issue**: #324, #421, #469
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user