mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
1 Commits
renovate/b
...
remove-ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e88b0711 |
52
.github/CONTRIBUTING.md
vendored
52
.github/CONTRIBUTING.md
vendored
@@ -20,61 +20,15 @@ npm run lint # Check lint errors
|
|||||||
npm run check # Run all checks (CI)
|
npm run check # Run all checks (CI)
|
||||||
```
|
```
|
||||||
|
|
||||||
Git hooks via Husky run automatically:
|
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||||
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
|
||||||
- **Pre-push**: Unit tests
|
|
||||||
|
|
||||||
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.
|
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
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
2. Make changes (pre-commit runs lint + type check automatically)
|
2. Make changes and ensure `npm run check` passes
|
||||||
3. Run E2E tests with `npm run test:e2e`
|
3. Submit PR against `main` with a clear description
|
||||||
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.
|
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
|
|||||||
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
@@ -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.
|
|
||||||
46
.github/renovate.json
vendored
46
.github/renovate.json
vendored
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
.github/workflows/auto-format.yml
vendored
29
.github/workflows/auto-format.yml
vendored
@@ -12,20 +12,21 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.head_ref }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Biome
|
||||||
|
run: npm install --save-dev @biomejs/biome
|
||||||
|
|
||||||
- name: Run Biome format
|
- name: Run Biome format
|
||||||
# Pin to the version in package.json so CI matches local/pre-commit
|
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
|
||||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
|
||||||
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
|
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: changes
|
id: changes
|
||||||
@@ -36,21 +37,11 @@ jobs:
|
|||||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
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
|
- name: Commit changes
|
||||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "github-actions[bot]"
|
git config --global user.name "github-actions[bot]"
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
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 add .
|
||||||
git commit -m "style: auto-format with Biome"
|
git commit -m "style: auto-format with Biome"
|
||||||
git push origin HEAD:${{ github.head_ref }}
|
git push
|
||||||
|
|||||||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -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
|
|
||||||
|
|
||||||
9
.github/workflows/docker-build.yml
vendored
9
.github/workflows/docker-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -54,12 +54,10 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -71,7 +69,7 @@ jobs:
|
|||||||
# Push to AWS ECR for App Runner auto-deploy
|
# Push to AWS ECR for App Runner auto-deploy
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
@@ -91,3 +89,4 @@ jobs:
|
|||||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
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 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
|
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
|
|
||||||
|
|||||||
87
.github/workflows/electron-release.yml
vendored
87
.github/workflows/electron-release.yml
vendored
@@ -11,8 +11,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Mac and Linux: Build and publish directly (no signing needed)
|
build:
|
||||||
build-mac-linux:
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -21,93 +20,27 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
platform: mac
|
platform: mac
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux
|
platform: linux
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 20
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Download draw.io static files for offline use
|
|
||||||
run: |
|
|
||||||
rm -rf public/drawio
|
|
||||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
|
||||||
mkdir -p public/drawio
|
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
|
||||||
rm -rf public/drawio/WEB-INF
|
|
||||||
rm -rf public/drawio/META-INF
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Build and publish
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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"
|
|
||||||
|
|
||||||
- name: Download draw.io static files for offline use
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
rm -rf public/drawio
|
|
||||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
|
||||||
mkdir -p public/drawio
|
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
|
||||||
rm -rf public/drawio/WEB-INF
|
|
||||||
rm -rf public/drawio/META-INF
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
# 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 }}
|
|
||||||
|
|||||||
67
.github/workflows/publish-mcp.yml
vendored
67
.github/workflows/publish-mcp.yml
vendored
@@ -1,67 +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: Publish to npm
|
|
||||||
if: steps.version.outputs.publish == 'true'
|
|
||||||
run: npm publish
|
|
||||||
85
.github/workflows/test.yml
vendored
85
.github/workflows/test.yml
vendored
@@ -1,85 +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
|
|
||||||
|
|
||||||
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
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -14,8 +14,6 @@ packages/*/dist
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
|
||||||
/test-results/
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -56,8 +54,6 @@ push-via-ec2.sh
|
|||||||
/dist-electron/
|
/dist-electron/
|
||||||
/release/
|
/release/
|
||||||
/electron-standalone/
|
/electron-standalone/
|
||||||
# Draw.io static files (downloaded during CI build)
|
|
||||||
public/drawio/
|
|
||||||
*.dmg
|
*.dmg
|
||||||
*.exe
|
*.exe
|
||||||
*.AppImage
|
*.AppImage
|
||||||
@@ -66,16 +62,4 @@ public/drawio/
|
|||||||
*.snap
|
*.snap
|
||||||
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.spec-workflow
|
.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 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
|
|
||||||
21
Dockerfile
21
Dockerfile
@@ -1,7 +1,7 @@
|
|||||||
# Multi-stage Dockerfile for Next.js
|
# Multi-stage Dockerfile for Next.js
|
||||||
|
|
||||||
# Stage 1: Install dependencies
|
# Stage 1: Install dependencies
|
||||||
FROM node:24-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,11 +9,10 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
RUN npm ci
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Stage 2: Build application
|
# Stage 2: Build application
|
||||||
FROM node:24-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy node_modules from deps stage
|
# Copy node_modules from deps stage
|
||||||
@@ -31,20 +30,11 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
|||||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
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)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Production runtime
|
# Stage 3: Production runtime
|
||||||
FROM node:24-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -61,9 +51,6 @@ COPY --from=builder /app/public ./public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
201
README.md
201
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
**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/)
|
[](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.
|
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
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
@@ -38,27 +26,21 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [Next AI Draw.io](#next-ai-drawio)
|
- [Next AI Draw.io ](#next-ai-drawio-)
|
||||||
- [Table of Contents](#table-of-contents)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [MCP Server](#mcp-server)
|
- [MCP Server (Preview)](#mcp-server-preview)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Try it Online](#try-it-online)
|
- [Try it Online](#try-it-online)
|
||||||
- [Desktop Application](#desktop-application)
|
- [Desktop Application](#desktop-application)
|
||||||
- [Run with Docker](#run-with-docker)
|
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [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)
|
- [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)
|
- [How It Works](#how-it-works)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
|
||||||
- [Star History](#star-history)
|
- [Star History](#star-history)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -76,24 +58,24 @@ Here are some example prompts and their generated diagrams:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG Technique Diagram</strong><br />
|
<strong>GCP architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<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/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Authentication using React and AWS</strong><br />
|
<strong>AWS architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<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/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Open Innovation</strong><br />
|
<strong>Azure architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<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/inno.svg" alt="Open Innovation Diagram" width="480" />
|
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<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>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||||
</td>
|
</td>
|
||||||
@@ -112,7 +94,9 @@ Here are some example prompts and their generated diagrams:
|
|||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **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
|
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server (Preview)
|
||||||
|
|
||||||
|
> **Preview Feature**: This feature is experimental and may not stable.
|
||||||
|
|
||||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||||
|
|
||||||
@@ -148,7 +132,7 @@ No installation needed! Try the app directly on our demo site:
|
|||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](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.
|
> **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.
|
||||||
|
|
||||||
@@ -156,11 +140,52 @@ No installation needed! Try the app directly on our demo site:
|
|||||||
|
|
||||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
||||||
|
|
||||||
Supported platforms: Windows, macOS, Linux.
|
| Platform | Download |
|
||||||
|
|----------|----------|
|
||||||
|
| macOS | `.dmg` (Intel & Apple Silicon) |
|
||||||
|
| Windows | `.exe` installer (x64 & ARM64) |
|
||||||
|
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
||||||
|
|
||||||
### Run with Docker
|
**Features:**
|
||||||
|
- **Native file dialogs**: Open/save `.drawio` files directly
|
||||||
|
- **Offline capable**: Works without internet after first launch
|
||||||
|
- **Built-in settings**: Configure AI providers directly in the app
|
||||||
|
|
||||||
[Go to Docker Guide](./docs/en/docker.md)
|
**Quick Setup:**
|
||||||
|
1. Download and install for your platform
|
||||||
|
2. Click the settings icon in the chat panel
|
||||||
|
3. Add your AI provider credentials
|
||||||
|
4. Start creating diagrams!
|
||||||
|
|
||||||
|
### Run with Docker (Recommended)
|
||||||
|
|
||||||
|
If you just want to run it locally, the best way is to use Docker.
|
||||||
|
|
||||||
|
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e AI_PROVIDER=openai \
|
||||||
|
-e AI_MODEL=gpt-4o \
|
||||||
|
-e OPENAI_API_KEY=your_api_key \
|
||||||
|
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
### Installation
|
||||||
|
|
||||||
@@ -169,85 +194,73 @@ Supported platforms: Windows, macOS, Linux.
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure your AI provider:
|
||||||
|
|
||||||
|
Create a `.env.local` file in the root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.example .env.local
|
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
|
```bash
|
||||||
npm run dev
|
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
|
## 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/).
|
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
Or you can deploy by this button.
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](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.
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-Provider Support
|
## 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)
|
- AWS Bedrock (default)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- 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.
|
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.
|
📖 **[Detailed Provider Configuration Guide](./docs/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.
|
|
||||||
|
|
||||||
**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.
|
**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, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||||
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -260,23 +273,33 @@ The application uses the following technologies:
|
|||||||
|
|
||||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||||
|
|
||||||
|
## 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
|
## 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!
|
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:
|
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||||
|
|
||||||
- Email: me[at]jiang.jp
|
- Email: me[at]jiang.jp
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "关于 - Next AI Draw.io",
|
title: "关于 - Next AI Draw.io",
|
||||||
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
|
|||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -61,73 +72,147 @@ export default function AboutCN() {
|
|||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
|
<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>
|
||||||
|
|
||||||
<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="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-yellow-400 opacity-20" />
|
<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">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story */}
|
{/* Story */}
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||||
<p>
|
<p>
|
||||||
好消息!感谢{" "}
|
大家对这个项目的热情太高了——看来大家都真的很喜欢画图!但这也带来了一个幸福的烦恼:我们经常触发出上游
|
||||||
<a
|
AI 接口的频率限制
|
||||||
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"
|
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
||||||
target="_blank"
|
</p>
|
||||||
rel="noopener noreferrer"
|
<p>
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
由于使用量过高,我已将模型从 Opus 4.5 更换为{" "}
|
||||||
>
|
|
||||||
字节跳动豆包
|
|
||||||
</a>
|
|
||||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
Haiku 4.5
|
||||||
</span>{" "}
|
|
||||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
|
||||||
50万免费Token
|
|
||||||
</span>
|
</span>
|
||||||
,适用于所有模型!
|
,以降低成本。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
作为一个
|
||||||
|
<span className="font-semibold text-amber-700">
|
||||||
|
独立开发者
|
||||||
|
</span>
|
||||||
|
,目前的 API
|
||||||
|
费用全是我自己在掏腰包(纯属为爱发电)。为了保证服务能细水长流,同时也为了避免我个人陷入财务危机,我还设置了以下临时用量限制:
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite Poster */}
|
{/* Limits Cards */}
|
||||||
<div className="text-center mb-5">
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
<a
|
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||||
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"
|
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||||
target="_blank"
|
Token 用量
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
>
|
<div className="text-lg font-bold text-gray-900">
|
||||||
<Image
|
{formatNumber(tpmLimit)}
|
||||||
src="/volcengine-invite.png"
|
<span className="text-sm font-normal text-gray-600">
|
||||||
alt="火山引擎方舟 Coding Plan"
|
/分钟
|
||||||
width={300}
|
</span>
|
||||||
height={400}
|
</div>
|
||||||
className="mx-auto rounded-lg"
|
<div className="text-lg font-bold text-gray-900">
|
||||||
/>
|
{formatNumber(dailyTokenLimit)}
|
||||||
</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* 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">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
使用自己的 API Key
|
使用自己的 API Key
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
您也可以使用自己的 API
|
您可以使用自己的 API Key
|
||||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
来绕过这些限制。点击聊天面板中的设置图标即可配置您的
|
||||||
|
Provider 和 API Key。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
您的 Key
|
您的 Key
|
||||||
仅保存在浏览器本地,不会被存储在服务器上。
|
仅保存在浏览器本地,不会被存储在服务器上。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sponsorship CTA */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
|
寻求赞助 (求大佬捞一把)
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
要想彻底解除这些限制,扩容后端是唯一的办法。我正在积极寻求
|
||||||
|
AI API 提供商或云平台的赞助。
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
作为回报(无论是额度支持还是资金支持),我将在
|
||||||
|
GitHub 仓库和 Live Demo
|
||||||
|
网站的显眼位置展示贵公司的 Logo
|
||||||
|
作为平台赞助商。
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:me@jiang.jp"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||||
|
>
|
||||||
|
联系我
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,106 +260,92 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ResNet50模型架构动画
|
动画Transformer连接器
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>提示词:</strong> 给我一个带有
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>动画连接器</strong>的Transformer架构图。
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="带动画连接器的Transformer架构"
|
||||||
alt="ResNet50模型架构图"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG技术图
|
GCP架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>提示词:</strong> 使用
|
||||||
architecture diagram for{" "}
|
<strong>GCP图标</strong>
|
||||||
<strong>chat application</strong>. Use
|
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP架构图"
|
||||||
alt="RAG架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
React和AWS认证流程
|
AWS架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>提示词:</strong> 使用
|
||||||
authentication process using React with{" "}
|
<strong>AWS图标</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS架构图"
|
||||||
alt="认证架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
敏捷Scrum流程
|
Azure架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>提示词:</strong> 使用
|
||||||
scrum workflow diagram for software
|
<strong>Azure图标</strong>
|
||||||
development team.
|
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure架构图"
|
||||||
alt="敏捷Scrum流程图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
开放式创新
|
猫咪素描
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>提示词:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
给我画一只可爱的猫。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫咪绘图"
|
||||||
alt="开放式创新图"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,16 +377,6 @@ export default function AboutCN() {
|
|||||||
多提供商支持
|
多提供商支持
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
|
||||||
<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>AWS Bedrock(默认)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI兼容API(通过{" "}
|
OpenAI / OpenAI兼容API(通过{" "}
|
||||||
@@ -323,13 +384,10 @@ export default function AboutCN() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||||
@@ -337,21 +395,18 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
支持与联系
|
<h2 className="text-2xl font-semibold text-gray-900">
|
||||||
</h2>
|
支持与联系
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
</h2>
|
||||||
特别感谢{" "}
|
<iframe
|
||||||
<a
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
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"
|
title="Sponsor DayuanJiang"
|
||||||
target="_blank"
|
height="32"
|
||||||
rel="noopener noreferrer"
|
width="114"
|
||||||
className="text-blue-600 hover:underline"
|
style={{ border: 0, borderRadius: 6 }}
|
||||||
>
|
/>
|
||||||
字节跳动豆包
|
</div>
|
||||||
</a>{" "}
|
|
||||||
为本站提供 API Token 支持!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
如果您觉得这个项目有用,请考虑{" "}
|
如果您觉得这个项目有用,请考虑{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "概要 - Next AI Draw.io",
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -69,54 +80,144 @@ export default function AboutJA() {
|
|||||||
AI搭載のダイアグラム作成ツール -
|
AI搭載のダイアグラム作成ツール -
|
||||||
チャット、描画、可視化
|
チャット、描画、可視化
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<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="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-yellow-400 opacity-20" />
|
<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">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story */}
|
{/* Story */}
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||||
<p>
|
<p>
|
||||||
朗報です!
|
予想以上の反響をいただき、ありがとうございます!皆様にダイアグラム作成を楽しんでいただいているのは嬉しい限りですが、その熱量により
|
||||||
<a
|
AI API のレート制限 (TPS/TPM)
|
||||||
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"
|
</p>
|
||||||
rel="noopener noreferrer"
|
<p>
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
利用量の増加に伴い、コスト削減のためモデルを
|
||||||
>
|
Opus 4.5 から{" "}
|
||||||
ByteDance Doubao
|
|
||||||
</a>
|
|
||||||
様のご支援により、デモサイトでは強力な{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
Haiku 4.5
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
に変更しました。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
私は現在、
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
50万トークン
|
個人開発者
|
||||||
</span>
|
</span>
|
||||||
が無料でもらえます!
|
として API
|
||||||
|
費用を全額自腹で負担しています。サービスを継続し、かつ私自身が借金を背負わないようにするため(笑)、一時的に以下の利用制限も設けさせていただきました。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
自分のAPIキーを使用
|
自分のAPIキーを使用
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
自分のAPIキーを使用することで、これらの制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sponsorship CTA */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
|
スポンサー募集
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
これらの制限を取り払い、バックエンドをスケールさせるには皆様の支援が必要です。現在、AI
|
||||||
|
API
|
||||||
|
プロバイダー様やクラウドプラットフォーム様からのスポンサー支援を積極的に募集しています。
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
ご支援(クレジット提供や資金援助)をいただける場合、GitHub
|
||||||
|
リポジトリおよびデモサイトにて、プラットフォームスポンサーとして貴社を大々的にご紹介させていただきます。
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:me@jiang.jp"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||||
|
>
|
||||||
|
お問い合わせ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,106 +269,93 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ResNet50モデルアーキテクチャアニメーション
|
アニメーションTransformerコネクタ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>プロンプト:</strong>{" "}
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>アニメーションコネクタ</strong>
|
||||||
of the ResNet50 model.
|
付きのTransformerアーキテクチャ図を作成してください。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||||
alt="ResNet50モデルアーキテクチャ図"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG技術ダイアグラム
|
GCPアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>プロンプト:</strong>{" "}
|
||||||
architecture diagram for{" "}
|
<strong>GCPアイコン</strong>
|
||||||
<strong>chat application</strong>. Use
|
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCPアーキテクチャ図"
|
||||||
alt="RAGアーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ReactとAWSによる認証
|
AWSアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>プロンプト:</strong>{" "}
|
||||||
authentication process using React with{" "}
|
<strong>AWSアイコン</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWSアーキテクチャ図"
|
||||||
alt="認証アーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
アジャイルスクラムプロセス
|
Azureアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>プロンプト:</strong>{" "}
|
||||||
scrum workflow diagram for software
|
<strong>Azureアイコン</strong>
|
||||||
development team.
|
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azureアーキテクチャ図"
|
||||||
alt="アジャイルスクラム図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
オープンイノベーション
|
猫のスケッチ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>プロンプト:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
かわいい猫を描いてください。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫の絵"
|
||||||
alt="オープンイノベーション図"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,16 +391,6 @@ export default function AboutJA() {
|
|||||||
マルチプロバイダーサポート
|
マルチプロバイダーサポート
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
|
||||||
<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>AWS Bedrock(デフォルト)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||||
@@ -320,13 +398,10 @@ export default function AboutJA() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注:<code>claude-sonnet-4-5</code>
|
注:<code>claude-sonnet-4-5</code>
|
||||||
@@ -334,21 +409,18 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
サポート&お問い合わせ
|
<h2 className="text-2xl font-semibold text-gray-900">
|
||||||
</h2>
|
サポート&お問い合わせ
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
</h2>
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
<iframe
|
||||||
<a
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
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"
|
title="Sponsor DayuanJiang"
|
||||||
target="_blank"
|
height="32"
|
||||||
rel="noopener noreferrer"
|
width="114"
|
||||||
className="text-blue-600 hover:underline"
|
style={{ border: 0, borderRadius: 6 }}
|
||||||
>
|
/>
|
||||||
ByteDance Doubao
|
</div>
|
||||||
</a>{" "}
|
|
||||||
様に、心より感謝申し上げます。
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "About - Next AI Draw.io",
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -69,60 +80,157 @@ export default function About() {
|
|||||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||||
Visualize
|
Visualize
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<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="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-yellow-400 opacity-20" />
|
<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">
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story */}
|
{/* Story */}
|
||||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||||
<p>
|
<p>
|
||||||
Great news! Thanks to the generous
|
The response to this project has been
|
||||||
sponsorship from{" "}
|
incredible—you all love making diagrams!
|
||||||
<a
|
However, this enthusiasm means we are
|
||||||
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"
|
frequently hitting the AI API rate limits
|
||||||
target="_blank"
|
(TPS/TPM). When this happens, the system
|
||||||
rel="noopener noreferrer"
|
pauses, leading to failed requests.
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
</p>
|
||||||
>
|
<p>
|
||||||
ByteDance Doubao
|
Due to the high usage, I have changed the
|
||||||
</a>
|
model from Opus 4.5 to{" "}
|
||||||
, the demo site now uses the powerful{" "}
|
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
Haiku 4.5
|
||||||
</span>{" "}
|
</span>
|
||||||
model for better diagram generation! Sign up
|
, which is more cost-effective.
|
||||||
via the link to get{" "}
|
</p>
|
||||||
|
<p>
|
||||||
|
As an{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
500K free tokens
|
indie developer
|
||||||
</span>{" "}
|
</span>
|
||||||
for all models!
|
, 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>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
Bring Your Own API Key
|
Bring Your Own API Key
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
You can also use your own API key with any
|
You can use your own API key to bypass these
|
||||||
supported provider. Click the Settings icon
|
limits. Click the Settings icon in the chat
|
||||||
in the chat panel to configure your provider
|
panel to configure your provider and API
|
||||||
and API key.
|
key.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
Your key is stored locally in your browser
|
Your key is stored locally in your browser
|
||||||
and is never stored on the server.
|
and is never stored on the server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sponsorship CTA */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
|
Call for Sponsorship
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
Scaling the backend is the only way to
|
||||||
|
remove these limits. I am actively seeking
|
||||||
|
sponsorship from AI API providers or Cloud
|
||||||
|
Platforms.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||||
|
In return for support (credits or funding),
|
||||||
|
I will prominently feature your company as a
|
||||||
|
platform sponsor on both the GitHub
|
||||||
|
repository and the live demo site.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:me@jiang.jp"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Contact Me
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,106 +290,96 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated ResNet50 Model Architecture
|
Animated Transformer Connectors
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>animated connector</strong> diagram of
|
||||||
of the ResNet50 model.
|
transformer's architecture.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="Transformer Architecture with Animated Connectors"
|
||||||
alt="Architecture diagram for ResNet50 model"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG Technique Diagram
|
GCP Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>Prompt:</strong> Generate a GCP
|
||||||
architecture diagram for{" "}
|
architecture diagram with{" "}
|
||||||
<strong>chat application</strong>. Use
|
<strong>GCP icons</strong>. Users connect to
|
||||||
connected diagram for data ingestion
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP Architecture Diagram"
|
||||||
alt="RAG Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Authentication using React and AWS
|
AWS Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>Prompt:</strong> Generate an AWS
|
||||||
authentication process using React with{" "}
|
architecture diagram with{" "}
|
||||||
<strong>AWS</strong>. Use Serverless
|
<strong>AWS icons</strong>. Users connect to
|
||||||
architecture.
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS Architecture Diagram"
|
||||||
alt="Authentication Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Agile Scrum Process
|
Azure Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>Prompt:</strong> Generate an Azure
|
||||||
scrum workflow diagram for software
|
architecture diagram with{" "}
|
||||||
development team.
|
<strong>Azure icons</strong>. Users connect
|
||||||
|
to a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure Architecture Diagram"
|
||||||
alt="Agile Scrum Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Open Innovation
|
Cat Sketch
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>Prompt:</strong> Draw a cute cat for
|
||||||
visualization of Henry Chesbrough's
|
me.
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="Cat Drawing"
|
||||||
alt="Open Innovation Diagram"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,16 +417,6 @@ export default function About() {
|
|||||||
Multi-Provider Support
|
Multi-Provider Support
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
|
||||||
<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>AWS Bedrock (default)</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||||
@@ -336,13 +424,10 @@ export default function About() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||||
@@ -352,21 +437,18 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
Support & Contact
|
<h2 className="text-2xl font-semibold text-gray-900">
|
||||||
</h2>
|
Support & Contact
|
||||||
<p className="text-gray-700 mb-4 font-semibold">
|
</h2>
|
||||||
Special thanks to{" "}
|
<iframe
|
||||||
<a
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
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"
|
title="Sponsor DayuanJiang"
|
||||||
target="_blank"
|
height="32"
|
||||||
rel="noopener noreferrer"
|
width="114"
|
||||||
className="text-blue-600 hover:underline"
|
style={{ border: 0, borderRadius: 6 }}
|
||||||
>
|
/>
|
||||||
ByteDance Doubao
|
</div>
|
||||||
</a>{" "}
|
|
||||||
for sponsoring the API token usage of the demo site!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you find this project useful, please consider{" "}
|
If you find this project useful, please consider{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -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,609 +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 {
|
|
||||||
FIXED_CRED_PROVIDERS,
|
|
||||||
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",
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
disabled,
|
|
||||||
password,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
providers: AdminProvider[]
|
|
||||||
envProviders: EnvProvider[]
|
|
||||||
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 = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
provider,
|
|
||||||
models: [],
|
|
||||||
isDefault: providers.length === 0,
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
<Select
|
|
||||||
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,610 +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)]
|
|
||||||
|
|
||||||
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[]>([])
|
|
||||||
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)
|
|
||||||
// Seed each toggle once from whether the group has configured
|
|
||||||
// values; don't stomp a user's explicit toggle on later saves
|
|
||||||
setEnabledGroups((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const group of SETTING_GROUPS) {
|
|
||||||
if (!group.toggleable || group.id in next) continue
|
|
||||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
|
||||||
(d) => map[d.key]?.source !== "default",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyProvidersResponse = useCallback(
|
|
||||||
(data: {
|
|
||||||
providers: AdminProvider[]
|
|
||||||
envProviders?: EnvProvider[]
|
|
||||||
}) => {
|
|
||||||
setProviders(data.providers)
|
|
||||||
setSavedProviders(JSON.stringify(data.providers))
|
|
||||||
setEnvProviders(data.envProviders ?? [])
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(key: string, value: string | null) => {
|
|
||||||
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 (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 only for values currently set
|
|
||||||
if (settings[key]?.source !== "default")
|
|
||||||
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}
|
|
||||||
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]
|
|
||||||
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
|
|
||||||
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}
|
|
||||||
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))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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,312 +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 as it was at mount, to revert to on empty
|
|
||||||
const [original] = useState(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
|
|
||||||
onChange: (value: string | null) => 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">
|
|
||||||
<SecretInput
|
|
||||||
id={inputId}
|
|
||||||
value={
|
|
||||||
isDirty
|
|
||||||
? (pendingValue ?? "")
|
|
||||||
: (secretState ?? currentValue)
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
onChange(typeof v === "string" ? v : "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -41,24 +41,19 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ lang: string }>
|
params: Promise<{ lang: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { lang: rawLang } = await params
|
const { lang: rawLang } = await params
|
||||||
const lang = (
|
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
||||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
|
||||||
) as Locale
|
|
||||||
|
|
||||||
// Default to English metadata
|
// Default to English metadata
|
||||||
const titles: Record<Locale, string> = {
|
const titles: Record<Locale, string> = {
|
||||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
zh: "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",
|
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptions: Record<Locale, string> = {
|
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.",
|
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.",
|
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.",
|
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 {
|
return {
|
||||||
@@ -85,14 +80,7 @@ export async function generateMetadata({
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
locale:
|
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
||||||
lang === "zh"
|
|
||||||
? "zh_CN"
|
|
||||||
: lang === "zh-Hant"
|
|
||||||
? "zh_HK"
|
|
||||||
: lang === "ja"
|
|
||||||
? "ja_JP"
|
|
||||||
: "en_US",
|
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
@@ -127,7 +115,6 @@ export async function generateMetadata({
|
|||||||
en: "/en",
|
en: "/en",
|
||||||
zh: "/zh",
|
zh: "/zh",
|
||||||
ja: "/ja",
|
ja: "/ja",
|
||||||
"zh-Hant": "/zh-Hant",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,65 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
|
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
const drawioBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const {
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
saveDiagramToStorage,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
|
||||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
const [isElectron, setIsElectron] = useState(false)
|
|
||||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
|
||||||
)
|
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
|
const isSavingRef = useRef(false)
|
||||||
|
const mouseOverDrawioRef = useRef(false)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
|
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaveDialog) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
isSavingRef.current = false
|
||||||
|
}, 1000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [showSaveDialog])
|
||||||
|
|
||||||
|
// Handle save from draw.io's built-in save button
|
||||||
|
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
||||||
|
// We use mouse position to determine if the user is interacting with draw.io
|
||||||
|
const handleDrawioSave = useCallback(() => {
|
||||||
|
if (!mouseOverDrawioRef.current) return
|
||||||
|
if (isSavingRef.current) return
|
||||||
|
isSavingRef.current = true
|
||||||
|
setShowSaveDialog(true)
|
||||||
|
}, [setShowSaveDialog])
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
|
||||||
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
|
||||||
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
|
||||||
const pathParts = pathname.split("/").filter(Boolean)
|
|
||||||
const currentLocale = pathParts[0]
|
|
||||||
if (currentLocale !== savedLocale) {
|
|
||||||
pathParts[0] = savedLocale
|
|
||||||
router.replace(`/${pathParts.join("/")}`)
|
|
||||||
return // Wait for redirect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (isDrawioTheme(savedUi)) {
|
if (savedUi === "min" || savedUi === "sketch") {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,42 +76,34 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect Electron and use bundled draw.io files for offline use
|
const savedCloseProtection = localStorage.getItem(
|
||||||
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
)
|
||||||
const electronDetected =
|
if (savedCloseProtection === "true") {
|
||||||
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
setCloseProtection(true)
|
||||||
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
|
||||||
if (electronDetected) {
|
|
||||||
setIsElectron(true)
|
|
||||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [])
|
||||||
|
|
||||||
const handleDrawioLoad = useCallback(() => {
|
const handleDarkModeChange = async () => {
|
||||||
setIsDrawioReady(true)
|
await saveDiagramToStorage()
|
||||||
onDrawioLoad()
|
|
||||||
}, [onDrawioLoad])
|
|
||||||
|
|
||||||
const handleDarkModeChange = () => {
|
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||||
document.documentElement.classList.toggle("dark", newValue)
|
document.documentElement.classList.toggle("dark", newValue)
|
||||||
setIsDrawioReady(false)
|
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
const handleDrawioUiChange = async () => {
|
||||||
localStorage.setItem("drawio-theme", theme)
|
await saveDiagramToStorage()
|
||||||
setDrawioUi(theme)
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
setIsDrawioReady(false)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
|
setDrawioUi(newUi)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - reset draw.io before crossing breakpoint
|
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
||||||
const isInitialRenderRef = useRef(true)
|
const isInitialRenderRef = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -115,7 +112,7 @@ export default function Home() {
|
|||||||
!isInitialRenderRef.current &&
|
!isInitialRenderRef.current &&
|
||||||
newIsMobile !== isMobileRef.current
|
newIsMobile !== isMobileRef.current
|
||||||
) {
|
) {
|
||||||
setIsDrawioReady(false)
|
saveDiagramToStorage().catch(() => {})
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
isMobileRef.current = newIsMobile
|
isMobileRef.current = newIsMobile
|
||||||
@@ -126,7 +123,7 @@ export default function Home() {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [resetDrawioReady])
|
}, [saveDiagramToStorage, resetDrawioReady])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -154,6 +151,20 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("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 (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -170,43 +181,34 @@ export default function Home() {
|
|||||||
className={`h-full relative ${
|
className={`h-full relative ${
|
||||||
isMobile ? "p-1" : "p-2"
|
isMobile ? "p-1" : "p-2"
|
||||||
}`}
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
mouseOverDrawioRef.current = true
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
mouseOverDrawioRef.current = false
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||||
{isLoaded && (
|
{isLoaded ? (
|
||||||
<div
|
<DrawIoEmbed
|
||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
key={`${drawioUi}-${darkMode}`}
|
||||||
>
|
ref={drawioRef}
|
||||||
<DrawIoEmbed
|
onExport={handleDiagramExport}
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
onLoad={onDrawioLoad}
|
||||||
ref={drawioRef}
|
onSave={handleDrawioSave}
|
||||||
autosave
|
baseUrl={drawioBaseUrl}
|
||||||
onAutoSave={handleDiagramAutoSave}
|
urlParameters={{
|
||||||
onExport={handleDiagramExport}
|
ui: drawioUi,
|
||||||
onLoad={handleDrawioLoad}
|
spin: true,
|
||||||
baseUrl={drawioBaseUrl}
|
libraries: false,
|
||||||
urlParameters={{
|
saveAndExit: false,
|
||||||
ui: drawioUi,
|
noExitBtn: true,
|
||||||
spin: false,
|
dark: darkMode,
|
||||||
libraries: false,
|
}}
|
||||||
saveAndExit: false,
|
/>
|
||||||
noSaveBtn: true,
|
) : (
|
||||||
noExitBtn: true,
|
<div className="h-full w-full flex items-center justify-center bg-background">
|
||||||
dark:
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||||
darkMode || drawioUi === "dark",
|
|
||||||
lang: currentLang,
|
|
||||||
// Enable offline mode in Electron to disable external service calls
|
|
||||||
...(isElectron && {
|
|
||||||
offline: true,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!isLoaded || !isDrawioReady) && (
|
|
||||||
<div className="h-full w-full bg-background flex items-center justify-center">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Draw.io panel is loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,23 +231,16 @@ export default function Home() {
|
|||||||
onExpand={() => setIsChatVisible(true)}
|
onExpand={() => setIsChatVisible(true)}
|
||||||
>
|
>
|
||||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||||
<Suspense
|
<ChatPanel
|
||||||
fallback={
|
isVisible={isChatVisible}
|
||||||
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
onToggleVisibility={toggleChatPanel}
|
||||||
Loading chat...
|
drawioUi={drawioUi}
|
||||||
</div>
|
onToggleDrawioUi={handleDrawioUiChange}
|
||||||
}
|
darkMode={darkMode}
|
||||||
>
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
<ChatPanel
|
isMobile={isMobile}
|
||||||
isVisible={isChatVisible}
|
onCloseProtectionChange={setCloseProtection}
|
||||||
onToggleVisibility={toggleChatPanel}
|
/>
|
||||||
drawioUi={drawioUi}
|
|
||||||
onDrawioUiChange={handleDrawioUiChange}
|
|
||||||
darkMode={darkMode}
|
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
|
||||||
AdminProvidersSchema,
|
|
||||||
deriveEnvUpdates,
|
|
||||||
loadAdminProviders,
|
|
||||||
maskAdminProviders,
|
|
||||||
mergeSecrets,
|
|
||||||
validateAdminProviders,
|
|
||||||
} from "@/lib/admin/providers"
|
|
||||||
import { 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,
|
|
||||||
})) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,66 +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"
|
|
||||||
|
|
||||||
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] : [],
|
|
||||||
)
|
|
||||||
|
|
||||||
return validateModel(
|
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: resolved.provider,
|
|
||||||
apiKey: resolved.apiKey,
|
|
||||||
baseUrl: resolved.baseUrl,
|
|
||||||
modelId: body.modelId,
|
|
||||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
|
||||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
|
||||||
awsRegion: resolved.awsRegion,
|
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import {
|
|
||||||
AIHUBMIX_MODELS_ENDPOINT,
|
|
||||||
extractAihubmixModelIds,
|
|
||||||
} from "@/lib/aihubmix-models"
|
|
||||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
const SUCCESS_CACHE_CONTROL =
|
|
||||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
|
||||||
|
|
||||||
function fallbackResponse() {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
models: SUGGESTED_MODELS.aihubmix || [],
|
|
||||||
source: "fallback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
|
||||||
next: { revalidate: 3600 },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(
|
|
||||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
|
||||||
)
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json()
|
|
||||||
const models = extractAihubmixModelIds(payload)
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
|
||||||
console.warn("[aihubmix-models] Model list response was empty")
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
models,
|
|
||||||
source: "aihubmix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,17 +12,8 @@ import fs from "fs/promises"
|
|||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import {
|
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||||
getAIModel,
|
|
||||||
SINGLE_SYSTEM_PROVIDERS,
|
|
||||||
supportsPromptCaching,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
|
||||||
isMinimalDiagram,
|
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
|
||||||
} from "@/lib/chat-helpers"
|
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -34,17 +25,97 @@ import {
|
|||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import {
|
|
||||||
resolveMaxOutputTokens,
|
|
||||||
withOutputTokenLimitFallback,
|
|
||||||
} from "@/lib/output-token-limit"
|
|
||||||
import { findServerModelById } from "@/lib/server-model-config"
|
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
export const maxDuration = 120
|
||||||
// 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.
|
// 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)
|
||||||
|
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||||
|
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
|
||||||
|
// Fix invalid/undefined inputs from interrupted streaming
|
||||||
|
if (
|
||||||
|
!part.input ||
|
||||||
|
typeof part.input !== "object" ||
|
||||||
|
Object.keys(part.input).length === 0
|
||||||
|
) {
|
||||||
|
// Skip tool calls with invalid inputs entirely
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
toolName === "display_diagram" ||
|
||||||
|
toolName === "edit_diagram"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
input: {
|
||||||
|
placeholder:
|
||||||
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||||
|
return { ...msg, content: replacedContent }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
@@ -95,12 +166,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||||
const { messages, xml, previousXml, sessionId } = body
|
|
||||||
const customSystemMessage =
|
|
||||||
typeof body.customSystemMessage === "string"
|
|
||||||
? body.customSystemMessage.slice(0, 5000)
|
|
||||||
: ""
|
|
||||||
|
|
||||||
// Get user ID for Langfuse tracking and quota
|
// Get user ID for Langfuse tracking and quota
|
||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
@@ -129,10 +195,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === SERVER-SIDE QUOTA CHECK START ===
|
// === SERVER-SIDE QUOTA CHECK START ===
|
||||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||||
const hasOwnApiKey = !!(
|
const hasOwnApiKey = !!(
|
||||||
req.headers.get("x-ai-provider") &&
|
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
||||||
(req.headers.get("x-ai-api-key") ||
|
|
||||||
req.headers.get("x-aws-access-key-id") ||
|
|
||||||
req.headers.get("x-vertex-api-key"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||||
@@ -181,45 +244,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === CACHE CHECK END ===
|
// === CACHE CHECK END ===
|
||||||
|
|
||||||
// Read client AI provider overrides from headers
|
// 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")
|
|
||||||
|
|
||||||
// For EdgeOne provider, construct full URL from request origin
|
|
||||||
// because createOpenAI needs absolute URL, not relative path
|
|
||||||
if (provider === "edgeone" && !baseUrl) {
|
|
||||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
|
||||||
baseUrl = `${origin}/api/edgeai`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
|
||||||
const cookieHeader = req.headers.get("cookie")
|
|
||||||
|
|
||||||
// Check if this is a server model with custom env var names
|
|
||||||
let serverModelConfig: {
|
|
||||||
apiKeyEnv?: string | string[]
|
|
||||||
baseUrlEnv?: string
|
|
||||||
provider?: string
|
|
||||||
} = {}
|
|
||||||
if (selectedModelId?.startsWith("server:")) {
|
|
||||||
const 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientOverrides = {
|
const clientOverrides = {
|
||||||
// Server model provider takes precedence over client header
|
provider: req.headers.get("x-ai-provider"),
|
||||||
provider: serverModelConfig.provider || provider,
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
baseUrl,
|
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
// AWS Bedrock credentials
|
// AWS Bedrock credentials
|
||||||
@@ -227,41 +254,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
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
|
|
||||||
...(provider === "edgeone" &&
|
|
||||||
cookieHeader && {
|
|
||||||
headers: { cookie: cookieHeader },
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
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
|
// Get AI model with optional client overrides
|
||||||
const {
|
const { model, providerOptions, headers, modelId } =
|
||||||
model: baseModel,
|
getAIModel(clientOverrides)
|
||||||
providerOptions,
|
|
||||||
headers,
|
|
||||||
modelId,
|
|
||||||
provider: resolvedProvider,
|
|
||||||
} = getAIModel(clientOverrides)
|
|
||||||
|
|
||||||
// Retry with a smaller budget if the provider rejects the requested one
|
|
||||||
const model = withOutputTokenLimitFallback(baseModel)
|
|
||||||
|
|
||||||
// User setting wins over server env, so desktop users can raise it themselves
|
|
||||||
const maxOutputTokens = resolveMaxOutputTokens(
|
|
||||||
req.headers.get("x-max-output-tokens"),
|
|
||||||
)
|
|
||||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
|
||||||
|
|
||||||
// Check if model supports prompt caching
|
// Check if model supports prompt caching
|
||||||
const shouldCache = supportsPromptCaching(modelId)
|
const shouldCache = supportsPromptCaching(modelId)
|
||||||
@@ -271,20 +271,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
const finalSystemMessage = customSystemMessage
|
|
||||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
|
||||||
: systemMessage
|
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts =
|
||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
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.
|
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
"""md
|
"""md
|
||||||
@@ -440,77 +432,40 @@ ${userInputText}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// System messages with multiple cache breakpoints for optimal caching:
|
// 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
|
// - 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
|
// This allows: if only user message changes, both system caches are reused
|
||||||
// Merge them into a single system message for compatibility
|
// if XML changes, instruction cache is still reused
|
||||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
const systemMessages = [
|
||||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
// Cache breakpoint 1: Instructions (rarely change)
|
||||||
const isCustomOpenAIEndpoint =
|
{
|
||||||
resolvedProvider === "openai" &&
|
role: "system" as const,
|
||||||
!!(
|
content: systemMessage,
|
||||||
baseUrl ||
|
...(shouldCache && {
|
||||||
process.env.OPENAI_BASE_URL ||
|
providerOptions: {
|
||||||
(serverModelConfig.baseUrlEnv &&
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
process.env[serverModelConfig.baseUrlEnv])
|
},
|
||||||
)
|
}),
|
||||||
const isSingleSystemProvider =
|
},
|
||||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||||
|
{
|
||||||
const xmlContext = `${
|
role: "system" as const,
|
||||||
previousXml
|
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!`,
|
||||||
? `Previous diagram XML (before user's last message):
|
...(shouldCache && {
|
||||||
"""xml
|
providerOptions: {
|
||||||
${previousXml}
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
"""
|
},
|
||||||
|
}),
|
||||||
`
|
},
|
||||||
: ""
|
]
|
||||||
}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. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
|
|
||||||
|
|
||||||
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: {
|
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
|
||||||
{
|
|
||||||
role: "system" as const,
|
|
||||||
content: xmlContext,
|
|
||||||
...(shouldCache && {
|
|
||||||
providerOptions: {
|
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
abortSignal: req.signal,
|
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
}),
|
||||||
maxOutputTokens,
|
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||||
@@ -535,13 +490,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||||
// Fix `= "` instead of `: "`
|
// Fix `= "` instead of `: "`
|
||||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||||
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
|
||||||
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
|
||||||
// Example: y="-20\" should be y=\"-20\"
|
|
||||||
inputToRepair = inputToRepair.replace(
|
|
||||||
/(\w+)="([^"]*?)\\"/g,
|
|
||||||
'$1=\\"$2\\"',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Use jsonrepair to fix truncated JSON
|
// Use jsonrepair to fix truncated JSON
|
||||||
const repairedInput = jsonrepair(inputToRepair)
|
const repairedInput = jsonrepair(inputToRepair)
|
||||||
@@ -657,7 +605,7 @@ Notes:
|
|||||||
Operations:
|
Operations:
|
||||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
- 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.
|
- 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.
|
- delete: Remove a cell by its id. Only cell_id is needed.
|
||||||
|
|
||||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||||
|
|
||||||
@@ -666,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
|||||||
Example - Add a rectangle:
|
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>"}]}
|
{"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):
|
Example - Delete a cell:
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
@@ -721,7 +669,7 @@ Available libraries:
|
|||||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||||
- Business: bpmn, lean_mapping
|
- Business: bpmn, lean_mapping
|
||||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||||
- UI/Mockups: android, material_design
|
- UI/Mockups: android
|
||||||
- Enterprise: citrix, sap, mscae, atlassian
|
- Enterprise: citrix, sap, mscae, atlassian
|
||||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||||
- Icons: webicons
|
- Icons: webicons
|
||||||
@@ -766,7 +714,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
if (
|
if (
|
||||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||||
) {
|
) {
|
||||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`[get_shape_library] Error loading "${library}":`,
|
`[get_shape_library] Error loading "${library}":`,
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import { extractFromHtml } from "@extractus/article-extractor"
|
|
||||||
import { NextResponse } from "next/server"
|
|
||||||
import TurndownService from "turndown"
|
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
|
||||||
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) {
|
|
||||||
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 response.arrayBuffer()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,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,136 +0,0 @@
|
|||||||
/**
|
|
||||||
* API endpoint for VLM-based diagram validation.
|
|
||||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { streamObject } from "ai"
|
|
||||||
import { getValidationModel } from "@/lib/ai-providers"
|
|
||||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
|
||||||
import {
|
|
||||||
type ValidationResult,
|
|
||||||
ValidationResultSchema,
|
|
||||||
} from "@/lib/validation-schema"
|
|
||||||
|
|
||||||
export const maxDuration = 30
|
|
||||||
|
|
||||||
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: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a streaming response for useObject compatibility.
|
|
||||||
* useObject expects text stream format, not plain JSON.
|
|
||||||
*/
|
|
||||||
function createStreamingResponse(result: ValidationResult): Response {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
// Stream the JSON as text (useObject parses this)
|
|
||||||
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
|
||||||
controller.close()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = streamObject({
|
|
||||||
model,
|
|
||||||
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: ({ object }) => {
|
|
||||||
if (sessionId && object) {
|
|
||||||
console.log(
|
|
||||||
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,23 +3,74 @@ import { createAnthropic } from "@ai-sdk/anthropic"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
import { createGateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
import {
|
|
||||||
AIHUBMIX_APP_CODE,
|
|
||||||
isAihubmixStandardBaseURL,
|
|
||||||
normalizeMiniMaxBaseURL,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
|
*/
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if (a === 10) return true
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if (a === 192 && b === 168) return true
|
||||||
|
// 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 169 && b === 254) return true
|
||||||
|
// 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 127) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Invalid URL - block it
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -29,8 +80,6 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
// Vertex AI specific
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -44,8 +93,6 @@ export async function POST(req: Request) {
|
|||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
// Note: Express Mode only needs vertexApiKey
|
|
||||||
vertexApiKey,
|
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -56,7 +103,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -74,17 +121,7 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (provider === "vertexai") {
|
} else if (provider !== "ollama" && !apiKey) {
|
||||||
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(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "API key is required" },
|
{ valid: false, error: "API key is required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -121,15 +158,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vertexai": {
|
|
||||||
const vertex = createVertex({
|
|
||||||
apiKey: vertexApiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = vertex(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const azure = createOpenAI({
|
const azure = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -158,28 +186,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "aihubmix": {
|
|
||||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
|
||||||
|
|
||||||
if (
|
|
||||||
isAihubmixStandardBaseURL(baseUrl) ||
|
|
||||||
baseUrl === defaultBaseURL
|
|
||||||
) {
|
|
||||||
const aihubmix = createAihubmix({
|
|
||||||
apiKey,
|
|
||||||
appCode: AIHUBMIX_APP_CODE,
|
|
||||||
})
|
|
||||||
model = aihubmix(modelId)
|
|
||||||
} else {
|
|
||||||
const aihubmixCompatible = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl,
|
|
||||||
})
|
|
||||||
model = aihubmixCompatible.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
if (baseUrl || apiKey) {
|
if (baseUrl || apiKey) {
|
||||||
const ds = createDeepSeek({
|
const ds = createDeepSeek({
|
||||||
@@ -196,28 +202,17 @@ export async function POST(req: Request) {
|
|||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const sf = createOpenAI({
|
const sf = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
||||||
})
|
})
|
||||||
model = sf.chat(modelId)
|
model = sf.chat(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ollama": {
|
case "ollama": {
|
||||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
const ollama = createOllama({
|
||||||
// OLLAMA_API_KEY when the URL is also from server config.
|
baseURL: baseUrl || "http://localhost:11434",
|
||||||
const ollamaApiKey = baseUrl
|
|
||||||
? apiKey || undefined
|
|
||||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
|
||||||
const ollamaProvider = createOllama({
|
|
||||||
baseURL:
|
|
||||||
baseUrl ||
|
|
||||||
process.env.OLLAMA_BASE_URL ||
|
|
||||||
"https://ollama.com/api",
|
|
||||||
...(ollamaApiKey && {
|
|
||||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
model = ollamaProvider(modelId)
|
model = ollama(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,179 +225,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "edgeone": {
|
|
||||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
|
||||||
// Need to pass cookies for EdgeOne Pages authentication
|
|
||||||
const cookieHeader = req.headers.get("cookie") || ""
|
|
||||||
const edgeone = createOpenAI({
|
|
||||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
|
||||||
baseURL: baseUrl || "/api/edgeai",
|
|
||||||
headers: {
|
|
||||||
cookie: cookieHeader,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
model = edgeone.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "sglang": {
|
|
||||||
// SGLang is OpenAI-compatible
|
|
||||||
const sglang = createOpenAI({
|
|
||||||
apiKey: apiKey || "not-needed",
|
|
||||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
|
||||||
})
|
|
||||||
model = sglang.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "doubao": {
|
|
||||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
|
||||||
const doubaoBaseUrl =
|
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
|
||||||
const lowerModelId = modelId.toLowerCase()
|
|
||||||
if (
|
|
||||||
lowerModelId.includes("deepseek") ||
|
|
||||||
lowerModelId.includes("kimi")
|
|
||||||
) {
|
|
||||||
const doubao = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao(modelId)
|
|
||||||
} else {
|
|
||||||
const doubao = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "modelscope": {
|
|
||||||
const baseURL =
|
|
||||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseURL}/chat/completions`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelId,
|
|
||||||
messages: [
|
|
||||||
{ role: "user", content: "Say 'OK'" },
|
|
||||||
],
|
|
||||||
max_tokens: 20,
|
|
||||||
stream: true,
|
|
||||||
enable_thinking: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
throw new Error(
|
|
||||||
`ModelScope API error (${response.status}): ${errorText}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") || ""
|
|
||||||
const isValidStreamingResponse =
|
|
||||||
response.status === 200 &&
|
|
||||||
(contentType.includes("text/event-stream") ||
|
|
||||||
contentType.includes("application/json"))
|
|
||||||
|
|
||||||
if (!isValidStreamingResponse) {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected response format: ${contentType}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime
|
|
||||||
|
|
||||||
if (response.body) {
|
|
||||||
response.body.cancel().catch(() => {
|
|
||||||
/* Ignore cancellation errors */
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: true,
|
|
||||||
responseTime,
|
|
||||||
note: "ModelScope model validated (using streaming API)",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[validate-model] ModelScope validation failed:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "minimax": {
|
|
||||||
const rawUrl =
|
|
||||||
baseUrl ||
|
|
||||||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
|
||||||
"https://api.minimaxi.com/anthropic"
|
|
||||||
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
|
||||||
normalizeMiniMaxBaseURL(rawUrl)
|
|
||||||
|
|
||||||
if (isAnthropicCompatible) {
|
|
||||||
const minimax = createAnthropic({
|
|
||||||
apiKey,
|
|
||||||
baseURL: minimaxBaseUrl,
|
|
||||||
})
|
|
||||||
model = minimax.chat(modelId)
|
|
||||||
} else {
|
|
||||||
const minimax = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: minimaxBaseUrl,
|
|
||||||
})
|
|
||||||
model = minimax.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
|
||||||
case "glm":
|
|
||||||
case "qwen":
|
|
||||||
case "kimi":
|
|
||||||
case "qiniu":
|
|
||||||
case "novita":
|
|
||||||
case "atlascloud":
|
|
||||||
case "mimo": {
|
|
||||||
const baseURL =
|
|
||||||
baseUrl ||
|
|
||||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
|
||||||
""
|
|
||||||
|
|
||||||
if (!baseURL) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: `No base URL configured for provider: ${provider}`,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openai = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL,
|
|
||||||
})
|
|
||||||
model = openai.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
|||||||
@@ -74,8 +74,8 @@
|
|||||||
--accent: oklch(0.94 0.03 280);
|
--accent: oklch(0.94 0.03 280);
|
||||||
--accent-foreground: oklch(0.35 0.08 270);
|
--accent-foreground: oklch(0.35 0.08 270);
|
||||||
|
|
||||||
/* Muted rose destructive */
|
/* Coral destructive */
|
||||||
--destructive: oklch(0.45 0.12 10);
|
--destructive: oklch(0.6 0.2 25);
|
||||||
|
|
||||||
/* Subtle borders */
|
/* Subtle borders */
|
||||||
--border: oklch(0.92 0.01 260);
|
--border: oklch(0.92 0.01 260);
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
--accent: oklch(0.3 0.04 280);
|
--accent: oklch(0.3 0.04 280);
|
||||||
--accent-foreground: oklch(0.9 0.03 270);
|
--accent-foreground: oklch(0.9 0.03 270);
|
||||||
|
|
||||||
--destructive: oklch(0.55 0.12 10);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
|
|
||||||
--border: oklch(0.28 0.015 260);
|
--border: oklch(0.28 0.015 260);
|
||||||
--input: oklch(0.25 0.015 260);
|
--input: oklch(0.25 0.015 260);
|
||||||
@@ -244,19 +244,6 @@
|
|||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: oklch(0.75 0.01 260);
|
background-color: oklch(0.75 0.01 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar */
|
|
||||||
.dark .scrollbar-thin {
|
|
||||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background-color: oklch(0.35 0.015 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: oklch(0.45 0.015 260);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth page transitions */
|
/* Smooth page transitions */
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false
|
||||||
"includes": ["**", "!public"]
|
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Cloud } from "lucide-react"
|
import { Cloud } from "lucide-react"
|
||||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|||||||
export const ModelSelectorList = ({
|
export const ModelSelectorList = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorListProps) => {
|
}: ModelSelectorListProps) => (
|
||||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
<div className="relative">
|
||||||
const [showShadow, setShowShadow] = useState(false)
|
<CommandList
|
||||||
|
className={cn(
|
||||||
useEffect(() => {
|
// Hide scrollbar on all platforms
|
||||||
const listElement = listRef.current
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
if (!listElement) return
|
className,
|
||||||
|
)}
|
||||||
const checkScroll = () => {
|
{...props}
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
/>
|
||||||
// Show shadow if there is more content below
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
// Using a small threshold to handle fractional pixel rendering
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||||
setShowShadow(
|
</div>
|
||||||
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 type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
@@ -177,7 +134,6 @@ export const ModelSelectorLogo = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
@@ -212,27 +168,3 @@ export const ModelSelectorName = ({
|
|||||||
}: ModelSelectorNameProps) => (
|
}: ModelSelectorNameProps) => (
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
<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>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -70,11 +70,9 @@ function ExampleCard({
|
|||||||
export default function ExamplePanel({
|
export default function ExamplePanel({
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
minimal = false,
|
|
||||||
}: {
|
}: {
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
minimal?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
|
||||||
@@ -122,52 +120,49 @@ export default function ExamplePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{!minimal && (
|
{/* MCP Server Notice */}
|
||||||
<>
|
<a
|
||||||
{/* MCP Server Notice */}
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
<a
|
target="_blank"
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
<div className="flex items-center gap-3">
|
||||||
>
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<Terminal className="w-4 h-4 text-purple-500" />
|
||||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
</div>
|
||||||
<Terminal className="w-4 h-4 text-purple-500" />
|
<div className="min-w-0">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0">
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
<div className="flex items-center gap-2">
|
{dict.examples.mcpServer}
|
||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
</span>
|
||||||
{dict.examples.mcpServer}
|
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
||||||
</span>
|
{dict.examples.preview}
|
||||||
</div>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.examples.mcpDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.examples.mcpDescription}
|
||||||
{/* Welcome section */}
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
|
||||||
{dict.examples.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
|
||||||
{dict.examples.subtitle}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</a>
|
||||||
|
|
||||||
|
{/* Welcome section */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.examples.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.examples.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Examples grid */}
|
{/* Examples grid */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{!minimal && (
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
{dict.examples.quickExamples}
|
||||||
{dict.examples.quickExamples}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ExampleCard
|
<ExampleCard
|
||||||
|
|||||||
@@ -1,42 +1,35 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Link,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
Square,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
|
||||||
import { isRealDiagram } from "@/lib/utils"
|
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -147,246 +140,118 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatInputRef {
|
|
||||||
focus: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string
|
input: string
|
||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
onStop?: () => void
|
onClearChat: () => void
|
||||||
files?: File[]
|
files?: File[]
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void
|
||||||
pdfData?: Map<
|
pdfData?: Map<
|
||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<string, UrlData>
|
showHistory?: boolean
|
||||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
onToggleHistory?: (show: boolean) => void
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
|
minimalStyle?: boolean
|
||||||
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
// Model selector props
|
// Model selector props
|
||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
// Focus control props
|
|
||||||
shouldFocus?: boolean
|
|
||||||
onFocused?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
export function ChatInput({
|
||||||
function ChatInput(
|
input,
|
||||||
{
|
status,
|
||||||
input,
|
onSubmit,
|
||||||
status,
|
onChange,
|
||||||
onSubmit,
|
onClearChat,
|
||||||
onChange,
|
files = [],
|
||||||
onStop,
|
onFileChange = () => {},
|
||||||
files = [],
|
pdfData = new Map(),
|
||||||
onFileChange = () => {},
|
showHistory = false,
|
||||||
pdfData = new Map(),
|
onToggleHistory = () => {},
|
||||||
urlData,
|
sessionId,
|
||||||
onUrlChange,
|
error = null,
|
||||||
sessionId,
|
minimalStyle = false,
|
||||||
error = null,
|
onMinimalStyleChange = () => {},
|
||||||
models = [],
|
models = [],
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onModelSelect = () => {},
|
onModelSelect = () => {},
|
||||||
onConfigureModels,
|
onConfigureModels = () => {},
|
||||||
showUnvalidatedModels = false,
|
}: ChatInputProps) {
|
||||||
shouldFocus = false,
|
const dict = useDictionary()
|
||||||
onFocused,
|
const {
|
||||||
},
|
diagramHistory,
|
||||||
ref,
|
saveDiagramToFile,
|
||||||
) {
|
showSaveDialog,
|
||||||
const dict = useDictionary()
|
setShowSaveDialog,
|
||||||
const {
|
} = useDiagram()
|
||||||
chartXML,
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
diagramHistory,
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
saveDiagramToFile,
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
showSaveDialog,
|
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||||
setShowSaveDialog,
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
} = useDiagram()
|
const isDisabled =
|
||||||
|
(status === "streaming" || status === "submitted") && !error
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const textarea = textareaRef.current
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
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])
|
||||||
|
|
||||||
// Expose focus method via ref
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
useImperativeHandle(ref, () => ({
|
onChange(e)
|
||||||
focus: () => {
|
adjustTextareaHeight()
|
||||||
textareaRef.current?.focus()
|
}
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Focus the textarea when shouldFocus becomes true
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
useEffect(() => {
|
e.preventDefault()
|
||||||
if (shouldFocus) {
|
const form = e.currentTarget.closest("form")
|
||||||
const timer = setTimeout(() => {
|
if (form && input.trim() && !isDisabled) {
|
||||||
textareaRef.current?.focus()
|
form.requestSubmit()
|
||||||
onFocused?.()
|
|
||||||
}, 150)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
}
|
||||||
}, [shouldFocus, onFocused])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
if (isDisabled) return
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
|
||||||
const isDisabled =
|
|
||||||
(status === "streaming" || status === "submitted") && !error
|
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const items = e.clipboardData.items
|
||||||
const textarea = textareaRef.current
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
if (textarea) {
|
item.type.startsWith("image/"),
|
||||||
textarea.style.height = "auto"
|
)
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}, [input, adjustTextareaHeight])
|
|
||||||
|
|
||||||
// Load send shortcut preference from localStorage and listen for changes
|
if (imageItems.length > 0) {
|
||||||
useEffect(() => {
|
const imageFiles = (
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
await Promise.all(
|
||||||
if (stored) setSendShortcut(stored)
|
imageItems.map(async (item, index) => {
|
||||||
|
const file = item.getAsFile()
|
||||||
const handleChange = (e: CustomEvent<string>) =>
|
if (!file) return null
|
||||||
setSendShortcut(e.detail)
|
return new File(
|
||||||
window.addEventListener(
|
[file],
|
||||||
"sendShortcutChange",
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
handleChange as EventListener,
|
{ type: file.type },
|
||||||
)
|
)
|
||||||
return () =>
|
}),
|
||||||
window.removeEventListener(
|
|
||||||
"sendShortcutChange",
|
|
||||||
handleChange as EventListener,
|
|
||||||
)
|
)
|
||||||
}, [])
|
).filter((f): f is File => f !== null)
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
onChange(e)
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
const shouldSend =
|
|
||||||
sendShortcut === "enter"
|
|
||||||
? e.key === "Enter" &&
|
|
||||||
!e.shiftKey &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.metaKey
|
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
|
||||||
|
|
||||||
if (shouldSend) {
|
|
||||||
e.preventDefault()
|
|
||||||
const form = e.currentTarget.closest("form")
|
|
||||||
if (form && input.trim() && !isDisabled) {
|
|
||||||
form.requestSubmit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const items = e.clipboardData.items
|
|
||||||
const imageItems = Array.from(items).filter((item) =>
|
|
||||||
item.type.startsWith("image/"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
|
||||||
const imageFiles = (
|
|
||||||
await Promise.all(
|
|
||||||
imageItems.map(async (item, index) => {
|
|
||||||
const file = item.getAsFile()
|
|
||||||
if (!file) return null
|
|
||||||
return new File(
|
|
||||||
[file],
|
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
|
||||||
{ type: file.type },
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).filter((f): f is File => f !== null)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
imageFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newFiles = Array.from(e.target.files || [])
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
newFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
|
||||||
isValidFileType(file),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
supportedFiles,
|
imageFiles,
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -395,244 +260,253 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUrlExtract = async (url: string) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!onUrlChange) return
|
const newFiles = Array.from(e.target.files || [])
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
setIsExtractingUrl(true)
|
newFiles,
|
||||||
|
files.length,
|
||||||
try {
|
dict,
|
||||||
const existing = urlData
|
)
|
||||||
? new Map(urlData)
|
showValidationErrors(errors, dict)
|
||||||
: new Map<string, UrlData>()
|
if (validFiles.length > 0) {
|
||||||
existing.set(url, {
|
onFileChange([...files, ...validFiles])
|
||||||
url,
|
|
||||||
title: url,
|
|
||||||
content: "",
|
|
||||||
charCount: 0,
|
|
||||||
isExtracting: true,
|
|
||||||
})
|
|
||||||
onUrlChange(existing)
|
|
||||||
|
|
||||||
const data = await extractUrlContent(url)
|
|
||||||
|
|
||||||
const newUrlData = new Map(existing)
|
|
||||||
newUrlData.set(url, data)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
|
|
||||||
setShowUrlDialog(false)
|
|
||||||
} catch (error) {
|
|
||||||
// Remove the URL from the data map on error
|
|
||||||
const newUrlData = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
newUrlData.delete(url)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
showErrorToast(
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to extract URL content"}
|
|
||||||
</span>,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsExtractingUrl(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (fileInputRef.current) {
|
||||||
<form
|
fileInputRef.current.value = ""
|
||||||
id="chat-form"
|
}
|
||||||
onSubmit={onSubmit}
|
}
|
||||||
className={`w-full transition-all duration-200 ${
|
|
||||||
isDragging
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||||
: ""
|
if (fileInputRef.current) {
|
||||||
}`}
|
fileInputRef.current.value = ""
|
||||||
onDragOver={handleDragOver}
|
}
|
||||||
onDragLeave={handleDragLeave}
|
}
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
const triggerFileInput = () => {
|
||||||
{/* File & URL previews */}
|
fileInputRef.current?.click()
|
||||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
}
|
||||||
<div className="mb-3">
|
|
||||||
<FilePreviewList
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
files={files}
|
e.preventDefault()
|
||||||
onRemoveFile={handleRemoveFile}
|
e.stopPropagation()
|
||||||
pdfData={pdfData}
|
setIsDragging(true)
|
||||||
urlData={urlData}
|
}
|
||||||
onRemoveUrl={
|
|
||||||
onUrlChange
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
? (url) => {
|
e.preventDefault()
|
||||||
const next = new Map(urlData)
|
e.stopPropagation()
|
||||||
next.delete(url)
|
setIsDragging(false)
|
||||||
onUrlChange(next)
|
}
|
||||||
}
|
|
||||||
: undefined
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
}
|
e.preventDefault()
|
||||||
/>
|
e.stopPropagation()
|
||||||
</div>
|
setIsDragging(false)
|
||||||
)}
|
|
||||||
<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">
|
if (isDisabled) return
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
const droppedFiles = e.dataTransfer.files
|
||||||
value={input}
|
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
onChange={handleChange}
|
isValidFileType(file),
|
||||||
onKeyDown={handleKeyDown}
|
)
|
||||||
onPaste={handlePaste}
|
|
||||||
placeholder={dict.chat.placeholder}
|
const { validFiles, errors } = validateFiles(
|
||||||
disabled={isDisabled}
|
supportedFiles,
|
||||||
aria-label="Chat input"
|
files.length,
|
||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
dict,
|
||||||
|
)
|
||||||
|
showValidationErrors(errors, dict)
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={dict.chat.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label="Chat input"
|
||||||
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowHistory(true)}
|
onClick={() => setShowClearDialog(true)}
|
||||||
disabled={
|
tooltipContent={dict.chat.clearConversation}
|
||||||
isDisabled || diagramHistory.length === 0
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
}
|
>
|
||||||
tooltipContent={dict.chat.diagramHistory}
|
<Trash2 className="h-4 w-4" />
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
</ButtonWithTooltip>
|
||||||
>
|
|
||||||
<History className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ResetWarningModal
|
||||||
type="button"
|
open={showClearDialog}
|
||||||
variant="ghost"
|
onOpenChange={setShowClearDialog}
|
||||||
size="sm"
|
onClear={handleClear}
|
||||||
onClick={() => setShowSaveDialog(true)}
|
/>
|
||||||
disabled={
|
|
||||||
isDisabled || !isRealDiagram(chartXML)
|
|
||||||
}
|
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<HistoryDialog
|
||||||
type="button"
|
showHistory={showHistory}
|
||||||
variant="ghost"
|
onToggleHistory={onToggleHistory}
|
||||||
size="sm"
|
/>
|
||||||
onClick={triggerFileInput}
|
|
||||||
disabled={isDisabled}
|
|
||||||
tooltipContent={dict.chat.uploadFile}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
{onUrlChange && (
|
<Tooltip>
|
||||||
<ButtonWithTooltip
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<div className="flex items-center gap-1.5">
|
||||||
variant="ghost"
|
<Switch
|
||||||
size="sm"
|
id="minimal-style"
|
||||||
onClick={() => setShowUrlDialog(true)}
|
checked={minimalStyle}
|
||||||
disabled={isDisabled}
|
onCheckedChange={onMinimalStyleChange}
|
||||||
tooltipContent={dict.chat.ExtractURL}
|
className="scale-75"
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
/>
|
||||||
>
|
<label
|
||||||
<Link className="h-4 w-4" />
|
htmlFor="minimal-style"
|
||||||
</ButtonWithTooltip>
|
className={`text-xs cursor-pointer select-none ${
|
||||||
)}
|
minimalStyle
|
||||||
|
? "text-primary font-medium"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{minimalStyle
|
||||||
|
? dict.chat.minimalStyle
|
||||||
|
: dict.chat.styledMode}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{dict.chat.minimalTooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<div className="flex items-center gap-1 overflow-hidden justify-end">
|
||||||
type="button"
|
<ButtonWithTooltip
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setShowSaveAsTemplate(true)}
|
size="sm"
|
||||||
disabled={isDisabled || !input.trim()}
|
onClick={() => onToggleHistory(true)}
|
||||||
tooltipContent={dict.templates.saveAsTemplate}
|
disabled={isDisabled || diagramHistory.length === 0}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
tooltipContent={dict.chat.diagramHistory}
|
||||||
>
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
<BookmarkPlus className="h-4 w-4" />
|
>
|
||||||
</ButtonWithTooltip>
|
<History className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
|
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={dict.chat.uploadFile}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
|
||||||
multiple
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
models={models}
|
models={models}
|
||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
onSelect={onModelSelect}
|
onSelect={onModelSelect}
|
||||||
onConfigure={onConfigureModels}
|
onConfigure={onConfigureModels}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
showUnvalidatedModels={showUnvalidatedModels}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
{(status === "streaming" || status === "submitted") &&
|
|
||||||
onStop ? (
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
type="button"
|
disabled={isDisabled || !input.trim()}
|
||||||
onClick={onStop}
|
size="sm"
|
||||||
size="sm"
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
variant="destructive"
|
aria-label={
|
||||||
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
isDisabled ? dict.chat.sending : dict.chat.send
|
||||||
aria-label={dict.chat.stopGeneration}
|
}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
{isDisabled ? (
|
||||||
</Button>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
type="submit"
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
disabled={isDisabled || !input.trim()}
|
{dict.chat.send}
|
||||||
size="sm"
|
</>
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
)}
|
||||||
aria-label={dict.chat.send}
|
</Button>
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 mr-1.5" />
|
|
||||||
{dict.chat.send}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HistoryDialog
|
</div>
|
||||||
showHistory={showHistory}
|
</form>
|
||||||
onToggleHistory={setShowHistory}
|
)
|
||||||
/>
|
}
|
||||||
<SaveDialog
|
|
||||||
open={showSaveDialog}
|
|
||||||
onOpenChange={setShowSaveDialog}
|
|
||||||
onSave={(filename, format) =>
|
|
||||||
saveDiagramToFile(
|
|
||||||
filename,
|
|
||||||
format,
|
|
||||||
sessionId,
|
|
||||||
dict.save.savedSuccessfully,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
defaultFilename={`diagram-${new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}`}
|
|
||||||
/>
|
|
||||||
{onUrlChange && (
|
|
||||||
<UrlInputDialog
|
|
||||||
open={showUrlDialog}
|
|
||||||
onOpenChange={setShowUrlDialog}
|
|
||||||
onSubmit={handleUrlExtract}
|
|
||||||
isExtracting={isExtractingUrl}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TemplateCreateDialog
|
|
||||||
open={showSaveAsTemplate}
|
|
||||||
onOpenChange={setShowSaveAsTemplate}
|
|
||||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
|
||||||
initialPrompt={input.trim()}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
|
Cpu,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
Link,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
@@ -26,23 +26,24 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
|
||||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import ExamplePanel from "./chat-example-panel"
|
||||||
|
import { CodeBlock } from "./code-block"
|
||||||
|
|
||||||
|
interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -56,26 +57,76 @@ function getCompleteOperations(
|
|||||||
["update", "add", "delete"].includes(op.operation) &&
|
["update", "add", "delete"].includes(op.operation) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
|
// delete doesn't need new_xml, update/add do
|
||||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool part interface for type safety
|
||||||
|
interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: {
|
||||||
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{operations.map((op, index) => (
|
||||||
|
<div
|
||||||
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
|
op.operation === "delete"
|
||||||
|
? "text-red-600"
|
||||||
|
: op.operation === "add"
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{op.operation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
cell_id: {op.cell_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{op.new_xml && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{op.new_xml}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
// Helper to split text content into regular text and file sections (PDF or text files)
|
||||||
interface TextSection {
|
interface TextSection {
|
||||||
type: "text" | "file" | "url"
|
type: "text" | "file"
|
||||||
content: string
|
content: string
|
||||||
filename?: string
|
filename?: string
|
||||||
charCount?: number
|
charCount?: number
|
||||||
fileType?: "pdf" | "text" | "url"
|
fileType?: "pdf" | "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||||
const sections: TextSection[] = []
|
const sections: TextSection[] = []
|
||||||
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
// Match [PDF: filename] or [File: filename] patterns
|
||||||
const filePattern =
|
const filePattern =
|
||||||
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -86,34 +137,28 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
|||||||
sections.push({ type: "text", content: beforeText })
|
sections.push({ type: "text", content: beforeText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file/url section
|
// Add file section
|
||||||
const sectionType = match[1].toLowerCase()
|
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
||||||
const fileType =
|
|
||||||
sectionType === "pdf"
|
|
||||||
? "pdf"
|
|
||||||
: sectionType === "url"
|
|
||||||
? "url"
|
|
||||||
: "text"
|
|
||||||
const filename = match[2].trim()
|
const filename = match[2].trim()
|
||||||
const content = match[3].trim()
|
const fileContent = match[3].trim()
|
||||||
sections.push({
|
sections.push({
|
||||||
type: sectionType === "url" ? "url" : "file",
|
type: "file",
|
||||||
content: content,
|
content: fileContent,
|
||||||
filename,
|
filename,
|
||||||
charCount: content.length,
|
charCount: fileContent.length,
|
||||||
fileType,
|
fileType,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length
|
lastIndex = match.index + match[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last section
|
// Add remaining text after last file section
|
||||||
const remainingText = text.slice(lastIndex).trim()
|
const remainingText = text.slice(lastIndex).trim()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
sections.push({ type: "text", content: remainingText })
|
sections.push({ type: "text", content: remainingText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no file/url sections found, return original text
|
// If no file sections found, return original text
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
sections.push({ type: "text", content: text })
|
sections.push({ type: "text", content: text })
|
||||||
}
|
}
|
||||||
@@ -132,18 +177,11 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
||||||
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
updatedAt: number
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
@@ -154,17 +192,6 @@ interface ChatMessageDisplayProps {
|
|||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||||
isRestored?: boolean
|
|
||||||
sessions?: SessionMetadata[]
|
|
||||||
onSelectSession?: (id: string) => void
|
|
||||||
onDeleteSession?: (id: string) => void
|
|
||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
|
||||||
validationStates?: Record<string, ValidationState>
|
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
|
||||||
onSendTemplate?: (
|
|
||||||
template: import("@/lib/template-storage").Template,
|
|
||||||
) => void
|
|
||||||
currentInput?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -177,37 +204,13 @@ export function ChatMessageDisplay({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
isRestored = false,
|
|
||||||
sessions = [],
|
|
||||||
onSelectSession,
|
|
||||||
onDeleteSession,
|
|
||||||
loadedMessageIdsRef,
|
|
||||||
validationStates = {},
|
|
||||||
onImproveWithSuggestions,
|
|
||||||
onSendTemplate,
|
|
||||||
currentInput = "",
|
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
const processedToolCalls = processedToolCallsRef
|
const processedToolCalls = processedToolCallsRef
|
||||||
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
||||||
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
// Reset refs when messages become empty (new chat or session switch)
|
|
||||||
// This ensures cached examples work correctly after starting a new session
|
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
previousXML.current = ""
|
|
||||||
lastProcessedXmlRef.current.clear()
|
|
||||||
// Note: processedToolCalls is passed from parent, so we clear it too
|
|
||||||
processedToolCalls.current.clear()
|
|
||||||
// Scroll to top to show newest history items
|
|
||||||
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
|
||||||
}
|
|
||||||
}, [messages.length, processedToolCalls])
|
|
||||||
// Debounce streaming diagram updates - store pending XML and timeout
|
// Debounce streaming diagram updates - store pending XML and timeout
|
||||||
const pendingXmlRef = useRef<string | null>(null)
|
const pendingXmlRef = useRef<string | null>(null)
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
@@ -225,12 +228,6 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
|
|
||||||
string | null
|
|
||||||
>(null)
|
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -245,44 +242,14 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
// Track "Save as Template" dialog
|
|
||||||
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
|
||||||
string | null
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
const setCopyState = (
|
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||||
messageId: string,
|
|
||||||
isToolCall: boolean,
|
|
||||||
isSuccess: boolean,
|
|
||||||
) => {
|
|
||||||
if (isSuccess) {
|
|
||||||
if (isToolCall) {
|
|
||||||
setCopiedToolCallId(messageId)
|
|
||||||
setTimeout(() => setCopiedToolCallId(null), 2000)
|
|
||||||
} else {
|
|
||||||
setCopiedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isToolCall) {
|
|
||||||
setCopyFailedToolCallId(messageId)
|
|
||||||
setTimeout(() => setCopyFailedToolCallId(null), 2000)
|
|
||||||
} else {
|
|
||||||
setCopyFailedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyMessageToClipboard = async (
|
|
||||||
messageId: string,
|
|
||||||
text: string,
|
|
||||||
isToolCall = false,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopyState(messageId, isToolCall, true)
|
|
||||||
} catch (_err) {
|
setCopiedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||||
|
} catch (err) {
|
||||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
// Fallback for non-secure contexts (HTTP) or permission denied
|
||||||
const textarea = document.createElement("textarea")
|
const textarea = document.createElement("textarea")
|
||||||
textarea.value = text
|
textarea.value = text
|
||||||
@@ -297,11 +264,15 @@ export function ChatMessageDisplay({
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
throw new Error("Copy command failed")
|
throw new Error("Copy command failed")
|
||||||
}
|
}
|
||||||
setCopyState(messageId, isToolCall, true)
|
setCopiedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
console.error("Failed to copy message:", fallbackErr)
|
console.error("Failed to copy message:", fallbackErr)
|
||||||
toast.error(dict.chat.failedToCopyDetail)
|
toast.error(
|
||||||
setCopyState(messageId, isToolCall, false)
|
"Failed to copy message. Please copy manually or check clipboard permissions.",
|
||||||
|
)
|
||||||
|
setCopyFailedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textarea)
|
document.body.removeChild(textarea)
|
||||||
}
|
}
|
||||||
@@ -333,7 +304,7 @@ export function ChatMessageDisplay({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to log feedback:", error)
|
console.error("Failed to log feedback:", error)
|
||||||
toast.error(dict.errors.failedToRecordFeedback)
|
toast.error("Failed to record your feedback. Please try again.")
|
||||||
// Revert optimistic UI update
|
// Revert optimistic UI update
|
||||||
setFeedback((prev) => {
|
setFeedback((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
@@ -346,6 +317,7 @@ export function ChatMessageDisplay({
|
|||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
let currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
// During streaming (showToast=false), extract only complete mxCell elements
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
@@ -369,9 +341,17 @@ export function ChatMessageDisplay({
|
|||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Use console.warn instead of console.error to avoid triggering
|
||||||
|
// Next.js dev mode error overlay for expected streaming states
|
||||||
|
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.malformedXml)
|
// Only log as error and show toast if this is the final XML
|
||||||
|
console.error(
|
||||||
|
"[ChatMessageDisplay] Malformed XML detected in final output",
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
"AI generated invalid diagram XML. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return // Skip this update
|
return // Skip this update
|
||||||
}
|
}
|
||||||
@@ -384,12 +364,18 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
|
const xmlProcessTime = performance.now() - startTime
|
||||||
|
|
||||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
// The quick DOM parse check above catches malformed XML
|
// The quick DOM parse check above catches malformed XML
|
||||||
// Full validation runs on final output (showToast=true)
|
// Full validation runs on final output (showToast=true)
|
||||||
if (!showToast) {
|
if (!showToast) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
|
const loadStartTime = performance.now()
|
||||||
onDisplayChart(replacedXML, true)
|
onDisplayChart(replacedXML, true)
|
||||||
|
console.log(
|
||||||
|
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,15 +385,37 @@ export function ChatMessageDisplay({
|
|||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
// Use fixed XML if available, otherwise use original
|
// Use fixed XML if available, otherwise use original
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
const xmlToLoad = validation.fixed || replacedXML
|
||||||
|
if (validation.fixes.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"[ChatMessageDisplay] Auto-fixed XML issues:",
|
||||||
|
validation.fixes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Skip validation in loadDiagram since we already validated above
|
||||||
|
const loadStartTime = performance.now()
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
|
console.log(
|
||||||
|
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
toast.error(dict.errors.validationFailed)
|
console.error(
|
||||||
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
|
validation.error,
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
"Diagram validation failed. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing XML:", error)
|
console.error(
|
||||||
|
"[ChatMessageDisplay] Error processing XML:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.failedToProcess)
|
toast.error(
|
||||||
|
"Failed to process diagram. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,33 +423,9 @@ export function ChatMessageDisplay({
|
|||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track previous message count to detect bulk loads vs streaming
|
|
||||||
const prevMessageCountRef = useRef(0)
|
|
||||||
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && messages.length > 0) {
|
if (messagesEndRef.current) {
|
||||||
const prevCount = prevMessageCountRef.current
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
const currentCount = messages.length
|
|
||||||
prevMessageCountRef.current = currentCount
|
|
||||||
|
|
||||||
// Bulk load (session restore) - instant scroll, no animation
|
|
||||||
if (prevCount === 0 || currentCount - prevCount > 1) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throttle scroll during streaming to avoid layout thrashing
|
|
||||||
// Leading + trailing: scroll immediately, then once more after cooldown
|
|
||||||
if (!scrollThrottleRef.current) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
||||||
scrollThrottleRef.current = setTimeout(() => {
|
|
||||||
scrollThrottleRef.current = null
|
|
||||||
messagesEndRef.current?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
})
|
|
||||||
}, 150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
@@ -464,15 +448,11 @@ export function ChatMessageDisplay({
|
|||||||
const toolPart = part as ToolPartLike
|
const toolPart = part as ToolPartLike
|
||||||
const { toolCallId, state, input } = toolPart
|
const { toolCallId, state, input } = toolPart
|
||||||
|
|
||||||
// Auto-collapse on completion, but only if user hasn't manually toggled
|
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => {
|
setExpandedTools((prev) => ({
|
||||||
// Only auto-collapse if not already set (user hasn't interacted)
|
...prev,
|
||||||
if (prev[toolCallId] === undefined) {
|
[toolCallId]: false,
|
||||||
return { ...prev, [toolCallId]: false }
|
}))
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -662,21 +642,149 @@ export function ChatMessageDisplay({
|
|||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
|
const callId = part.toolCallId
|
||||||
|
const { state, input, output } = part
|
||||||
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
|
const toolName = part.type?.replace("tool-", "")
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[callId]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolDisplayName = (name: string) => {
|
||||||
|
switch (name) {
|
||||||
|
case "display_diagram":
|
||||||
|
return "Generate Diagram"
|
||||||
|
case "edit_diagram":
|
||||||
|
return "Edit Diagram"
|
||||||
|
case "get_shape_library":
|
||||||
|
return "Get Shape Library"
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={callId}
|
||||||
|
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground/80">
|
||||||
|
{getToolDisplayName(toolName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{state === "input-streaming" && (
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "output-available" && (
|
||||||
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return isTruncated ? (
|
||||||
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
|
Truncated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{input && Object.keys(input).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{input && isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
|
{typeof input === "object" && input.xml ? (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
input.operations &&
|
||||||
|
Array.isArray(input.operations) ? (
|
||||||
|
<OperationsDisplay operations={input.operations} />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
Object.keys(input).length > 0 ? (
|
||||||
|
<CodeBlock
|
||||||
|
code={JSON.stringify(input, null, 2)}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{output &&
|
||||||
|
state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||||
|
: output}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Show get_shape_library output on success */}
|
||||||
|
{output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
state === "output-available" &&
|
||||||
|
isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
Library loaded (
|
||||||
|
{typeof output === "string" ? output.length : 0}{" "}
|
||||||
|
chars)
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||||
|
{typeof output === "string"
|
||||||
|
? output.substring(0, 800) +
|
||||||
|
(output.length > 800 ? "\n..." : "")
|
||||||
|
: String(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
<div ref={scrollTopRef} />
|
{messages.length === 0 ? (
|
||||||
{messages.length === 0 && isRestored ? (
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
<ChatLobby
|
) : (
|
||||||
sessions={sessions}
|
|
||||||
onSelectSession={onSelectSession || (() => {})}
|
|
||||||
onDeleteSession={onDeleteSession}
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
dict={dict}
|
|
||||||
/>
|
|
||||||
) : messages.length === 0 ? null : (
|
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 px-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText =
|
||||||
@@ -696,21 +804,13 @@ export function ChatMessageDisplay({
|
|||||||
.slice(messageIndex + 1)
|
.slice(messageIndex + 1)
|
||||||
.every((m) => m.role !== "user"))
|
.every((m) => m.role !== "user"))
|
||||||
const isEditing = editingMessageId === message.id
|
const isEditing = editingMessageId === message.id
|
||||||
// Skip animation for loaded messages (from session restore)
|
|
||||||
const isRestoredMessage =
|
|
||||||
loadedMessageIdsRef?.current.has(message.id) ??
|
|
||||||
false
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||||
style={
|
style={{
|
||||||
isRestoredMessage
|
animationDelay: `${messageIndex * 50}ms`,
|
||||||
? undefined
|
}}
|
||||||
: {
|
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" &&
|
||||||
userMessageText &&
|
userMessageText &&
|
||||||
@@ -732,10 +832,7 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
title={
|
title="Edit message"
|
||||||
dict.chat
|
|
||||||
.editMessage
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -752,13 +849,11 @@ export function ChatMessageDisplay({
|
|||||||
title={
|
title={
|
||||||
copiedMessageId ===
|
copiedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat.copied
|
? "Copied!"
|
||||||
: copyFailedMessageId ===
|
: copyFailedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat
|
? "Failed to copy"
|
||||||
.failedToCopy
|
: "Copy message"
|
||||||
: dict.chat
|
|
||||||
.copyResponse
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId ===
|
||||||
@@ -771,42 +866,8 @@ export function ChatMessageDisplay({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Save as Template button - only for user messages */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setSaveAsTemplateMessageId(
|
|
||||||
message.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
dict.templates
|
|
||||||
?.saveAsTemplate ||
|
|
||||||
"Save as Template"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save as Template Dialog */}
|
|
||||||
{saveAsTemplateMessageId === message.id && (
|
|
||||||
<TemplateCreateDialog
|
|
||||||
open={true}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open)
|
|
||||||
setSaveAsTemplateMessageId(null)
|
|
||||||
}}
|
|
||||||
onSuccess={() => {
|
|
||||||
setSaveAsTemplateMessageId(null)
|
|
||||||
}}
|
|
||||||
initialPrompt={getUserOriginalText(
|
|
||||||
message,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%] min-w-0">
|
||||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||||
{message.role === "assistant" &&
|
{message.role === "assistant" &&
|
||||||
@@ -841,9 +902,6 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
defaultOpen={
|
|
||||||
!isRestoredMessage
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
@@ -910,7 +968,7 @@ export function ChatMessageDisplay({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||||
>
|
>
|
||||||
{dict.common.cancel}
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -932,7 +990,7 @@ export function ChatMessageDisplay({
|
|||||||
disabled={!editText.trim()}
|
disabled={!editText.trim()}
|
||||||
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{dict.chat.saveAndSubmit}
|
Save & Submit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -986,56 +1044,9 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
const toolPart = group
|
return renderToolPart(
|
||||||
.parts[0] as ToolPartLike
|
group
|
||||||
const toolCallId =
|
.parts[0] as ToolPartLike,
|
||||||
toolPart.toolCallId
|
|
||||||
const isDisplayDiagram =
|
|
||||||
toolPart.type ===
|
|
||||||
"tool-display_diagram"
|
|
||||||
const validationState =
|
|
||||||
validationStates[
|
|
||||||
toolCallId
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${message.id}-tool-${group.startIndex}`}
|
|
||||||
>
|
|
||||||
<ToolCallCard
|
|
||||||
part={
|
|
||||||
toolPart
|
|
||||||
}
|
|
||||||
expandedTools={
|
|
||||||
expandedTools
|
|
||||||
}
|
|
||||||
setExpandedTools={
|
|
||||||
setExpandedTools
|
|
||||||
}
|
|
||||||
onCopy={
|
|
||||||
copyMessageToClipboard
|
|
||||||
}
|
|
||||||
copiedToolCallId={
|
|
||||||
copiedToolCallId
|
|
||||||
}
|
|
||||||
copyFailedToolCallId={
|
|
||||||
copyFailedToolCallId
|
|
||||||
}
|
|
||||||
dict={dict}
|
|
||||||
/>
|
|
||||||
{/* Show validation card for display_diagram tools */}
|
|
||||||
{isDisplayDiagram &&
|
|
||||||
validationState && (
|
|
||||||
<ValidationCard
|
|
||||||
state={
|
|
||||||
validationState
|
|
||||||
}
|
|
||||||
onImproveWithSuggestions={
|
|
||||||
onImproveWithSuggestions
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,8 +1123,7 @@ export function ChatMessageDisplay({
|
|||||||
"user" &&
|
"user" &&
|
||||||
isLastUserMessage &&
|
isLastUserMessage &&
|
||||||
onEditMessage
|
onEditMessage
|
||||||
? dict.chat
|
? "Click to edit"
|
||||||
.clickToEdit
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1149,14 +1159,12 @@ export function ChatMessageDisplay({
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
section.type ===
|
section.type ===
|
||||||
"file" ||
|
"file"
|
||||||
section.type ===
|
|
||||||
"url"
|
|
||||||
) {
|
) {
|
||||||
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
expandedPdfSections[
|
expandedPdfSections[
|
||||||
sectionKey
|
pdfKey
|
||||||
] ??
|
] ??
|
||||||
false
|
false
|
||||||
const charDisplay =
|
const charDisplay =
|
||||||
@@ -1165,27 +1173,10 @@ export function ChatMessageDisplay({
|
|||||||
1000
|
1000
|
||||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||||
: section.charCount
|
: section.charCount
|
||||||
|
|
||||||
// Icon selector
|
|
||||||
const Icon =
|
|
||||||
section.fileType ===
|
|
||||||
"pdf"
|
|
||||||
? FileText
|
|
||||||
: section.fileType ===
|
|
||||||
"url"
|
|
||||||
? Link
|
|
||||||
: FileCode
|
|
||||||
|
|
||||||
const iconColor =
|
|
||||||
section.fileType ===
|
|
||||||
"pdf"
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-blue-700"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
sectionKey
|
pdfKey
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -1200,7 +1191,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sectionKey]:
|
[pdfKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1208,10 +1199,13 @@ export function ChatMessageDisplay({
|
|||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon
|
{section.fileType ===
|
||||||
className={`h-4 w-4 ${iconColor}`}
|
"pdf" ? (
|
||||||
/>
|
<FileText className="h-4 w-4 text-red-500" />
|
||||||
<span className="text-xs font-medium truncate max-w-[200px]">
|
) : (
|
||||||
|
<FileCode className="h-4 w-4 text-blue-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">
|
||||||
{
|
{
|
||||||
section.filename
|
section.filename
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1225,7 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
|
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
|
||||||
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
||||||
{
|
{
|
||||||
section.content
|
section.content
|
||||||
@@ -1331,8 +1325,8 @@ export function ChatMessageDisplay({
|
|||||||
title={
|
title={
|
||||||
copiedMessageId ===
|
copiedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat.copied
|
? "Copied!"
|
||||||
: dict.chat.copyResponse
|
: "Copy response"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId ===
|
||||||
@@ -1358,9 +1352,7 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||||
title={
|
title="Regenerate response"
|
||||||
dict.chat.regenerate
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1382,7 +1374,7 @@ export function ChatMessageDisplay({
|
|||||||
? "text-green-600 bg-green-100"
|
? "text-green-600 bg-green-100"
|
||||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
||||||
}`}
|
}`}
|
||||||
title={dict.chat.goodResponse}
|
title="Good response"
|
||||||
>
|
>
|
||||||
<ThumbsUp className="h-3.5 w-3.5" />
|
<ThumbsUp className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1401,7 +1393,7 @@ export function ChatMessageDisplay({
|
|||||||
? "text-red-600 bg-red-100"
|
? "text-red-600 bg-red-100"
|
||||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
||||||
}`}
|
}`}
|
||||||
title={dict.chat.badResponse}
|
title="Bad response"
|
||||||
>
|
>
|
||||||
<ThumbsDown className="h-3.5 w-3.5" />
|
<ThumbsDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,358 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
MessageSquare,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import type { Template } from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface SessionMetadata {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
updatedAt: number
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatLobbyProps {
|
|
||||||
sessions: SessionMetadata[]
|
|
||||||
onSelectSession: (id: string) => void
|
|
||||||
onDeleteSession?: (id: string) => void
|
|
||||||
setInput: (input: string) => void
|
|
||||||
setFiles: (files: File[]) => void
|
|
||||||
onSendTemplate?: (template: Template) => void
|
|
||||||
currentInput?: string
|
|
||||||
dict: {
|
|
||||||
sessionHistory?: {
|
|
||||||
recentChats?: string
|
|
||||||
searchPlaceholder?: string
|
|
||||||
noResults?: string
|
|
||||||
justNow?: string
|
|
||||||
deleteTitle?: string
|
|
||||||
deleteDescription?: string
|
|
||||||
}
|
|
||||||
templates?: {
|
|
||||||
title?: string
|
|
||||||
myTemplates?: string
|
|
||||||
}
|
|
||||||
examples?: {
|
|
||||||
quickExamples?: string
|
|
||||||
}
|
|
||||||
common: {
|
|
||||||
delete: string
|
|
||||||
cancel: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format session date
|
|
||||||
function formatSessionDate(
|
|
||||||
timestamp: number,
|
|
||||||
dict?: { justNow?: string },
|
|
||||||
): string {
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
|
|
||||||
if (diffMins < 1) return dict?.justNow || "Just now"
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`
|
|
||||||
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPanelVisibility() {
|
|
||||||
if (typeof window === "undefined")
|
|
||||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
|
||||||
return {
|
|
||||||
recentChats:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
|
||||||
myTemplates:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
|
||||||
quickExamples:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatLobby({
|
|
||||||
sessions,
|
|
||||||
onSelectSession,
|
|
||||||
onDeleteSession,
|
|
||||||
setInput,
|
|
||||||
setFiles,
|
|
||||||
onSendTemplate,
|
|
||||||
currentInput = "",
|
|
||||||
dict,
|
|
||||||
}: ChatLobbyProps) {
|
|
||||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
|
||||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
|
|
||||||
// Listen for panel visibility changes from settings
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
|
||||||
window.addEventListener("panelVisibilityChange", handler)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("panelVisibilityChange", handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const hasHistory = sessions.length > 0
|
|
||||||
|
|
||||||
if (!hasHistory) {
|
|
||||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
{panelVisibility.myTemplates && (
|
|
||||||
<TemplatePanel
|
|
||||||
setInput={setInput}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{panelVisibility.quickExamples && (
|
|
||||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
|
||||||
return (
|
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
|
||||||
{/* Recent Chats Section */}
|
|
||||||
{panelVisibility.recentChats && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
|
||||||
</p>
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="relative mb-3">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={
|
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
|
||||||
"Search chats..."
|
|
||||||
}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sessions
|
|
||||||
.filter((session) =>
|
|
||||||
session.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((session) => (
|
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
||||||
onClick={() => onSelectSession(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
e.key === "Enter" ||
|
|
||||||
e.key === " "
|
|
||||||
) {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectSession(session.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.thumbnailDataUrl ? (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={session.thumbnailDataUrl}
|
|
||||||
alt=""
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="object-contain w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<MessageSquare className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">
|
|
||||||
{session.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatSessionDate(
|
|
||||||
session.updatedAt,
|
|
||||||
dict.sessionHistory,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onDeleteSession && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSessionToDelete(session.id)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
title={dict.common.delete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.filter((s) =>
|
|
||||||
s.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
).length === 0 &&
|
|
||||||
searchQuery && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
{dict.sessionHistory?.noResults ||
|
|
||||||
"No chats found"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collapsible My Templates Section */}
|
|
||||||
{panelVisibility.myTemplates && (
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.templates?.myTemplates || "My Templates"}
|
|
||||||
</span>
|
|
||||||
{templatesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{templatesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<TemplatePanel
|
|
||||||
setInput={setInput}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collapsible Quick Examples Section */}
|
|
||||||
{panelVisibility.quickExamples && (
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.examples?.quickExamples || "Quick Examples"}
|
|
||||||
</span>
|
|
||||||
{examplesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{examplesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<ExamplePanel
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
minimal
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
>
|
|
||||||
<AlertDialogContent className="max-w-sm">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{dict.sessionHistory?.deleteTitle ||
|
|
||||||
"Delete this chat?"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{dict.sessionHistory?.deleteDescription ||
|
|
||||||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{dict.common.cancel}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
if (sessionToDelete && onDeleteSession) {
|
|
||||||
onDeleteSession(sessionToDelete)
|
|
||||||
}
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setSessionToDelete(null)
|
|
||||||
}}
|
|
||||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
|
||||||
>
|
|
||||||
{dict.common.delete}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +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()
|
|
||||||
|
|
||||||
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,215 +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 when template changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (template) {
|
|
||||||
setTitle(template.title || "")
|
|
||||||
setDescription(template.description || "")
|
|
||||||
setPrompt(template.prompt || "")
|
|
||||||
setPinned(template.pinned || false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [template])
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
if (!newOpen) {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
onOpenChange(newOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
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,622 +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 { TemplateCreateDialog } from "./TemplateCreateDialog"
|
|
||||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
|
||||||
|
|
||||||
interface TemplatePanelProps {
|
|
||||||
setInput: (input: string) => void
|
|
||||||
onSendTemplate?: (template: Template) => void
|
|
||||||
currentInput?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = "",
|
|
||||||
}: TemplatePanelProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
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
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTemplates()
|
|
||||||
}, [loadTemplates])
|
|
||||||
|
|
||||||
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
|
|
||||||
a.download = `templates-${new Date().toISOString().split("T")[0]}.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 = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state: no templates at all
|
|
||||||
if (!loading && templates.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="application/json,.json"
|
|
||||||
onChange={handleImport}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Import message */}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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) => {
|
|
||||||
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,265 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
|
||||||
import type { Dispatch, SetStateAction } from "react"
|
|
||||||
import { CodeBlock } from "@/components/code-block"
|
|
||||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
|
||||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
|
||||||
|
|
||||||
interface ToolCallCardProps {
|
|
||||||
part: ToolPartLike
|
|
||||||
expandedTools: Record<string, boolean>
|
|
||||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
|
||||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
|
||||||
copiedToolCallId: string | null
|
|
||||||
copyFailedToolCallId: string | null
|
|
||||||
dict: {
|
|
||||||
tools: { complete: string }
|
|
||||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{operations.map((op, index) => (
|
|
||||||
<div
|
|
||||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
||||||
op.operation === "delete"
|
|
||||||
? "text-red-600"
|
|
||||||
: op.operation === "add"
|
|
||||||
? "text-green-600"
|
|
||||||
: "text-blue-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{op.operation}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
cell_id: {op.cell_id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{op.new_xml && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
|
||||||
{op.new_xml}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolCallCard({
|
|
||||||
part,
|
|
||||||
expandedTools,
|
|
||||||
setExpandedTools,
|
|
||||||
onCopy,
|
|
||||||
copiedToolCallId,
|
|
||||||
copyFailedToolCallId,
|
|
||||||
dict,
|
|
||||||
}: ToolCallCardProps) {
|
|
||||||
const callId = part.toolCallId
|
|
||||||
const { state, input, output } = part
|
|
||||||
// Default to expanded for all states (user can manually collapse if needed)
|
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
|
||||||
const toolName = part.type?.replace("tool-", "")
|
|
||||||
const isCopied = copiedToolCallId === callId
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpandedTools((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[callId]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
|
||||||
switch (name) {
|
|
||||||
case "display_diagram":
|
|
||||||
return "Generate Diagram"
|
|
||||||
case "edit_diagram":
|
|
||||||
return "Edit Diagram"
|
|
||||||
case "get_shape_library":
|
|
||||||
return "Get Shape Library"
|
|
||||||
default:
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
let textToCopy = ""
|
|
||||||
|
|
||||||
if (input && typeof input === "object") {
|
|
||||||
if (input.xml) {
|
|
||||||
textToCopy = input.xml
|
|
||||||
} else if (input.operations && Array.isArray(input.operations)) {
|
|
||||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
||||||
} else if (Object.keys(input).length > 0) {
|
|
||||||
textToCopy = JSON.stringify(input, null, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
typeof output === "string"
|
|
||||||
) {
|
|
||||||
textToCopy = output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textToCopy) {
|
|
||||||
onCopy(callId, textToCopy, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground/80">
|
|
||||||
{getToolDisplayName(toolName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{state === "input-streaming" && (
|
|
||||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
{state === "output-available" && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
||||||
{dict.tools.complete}
|
|
||||||
</span>
|
|
||||||
{isExpanded && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
copiedToolCallId === callId
|
|
||||||
? dict.chat.copied
|
|
||||||
: copyFailedToolCallId === callId
|
|
||||||
? dict.chat.failedToCopy
|
|
||||||
: dict.chat.copyResponse
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="w-4 h-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
// Check if this is a truncation (incomplete XML) vs real error
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return isTruncated ? (
|
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
||||||
Truncated
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{input && Object.keys(input).length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{input && isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
||||||
{typeof input === "object" && input.xml ? (
|
|
||||||
state === "input-streaming" ||
|
|
||||||
state === "input-available" ? (
|
|
||||||
<pre
|
|
||||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
"var(--font-mono), ui-monospace, monospace",
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{input.xml}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
|
||||||
)
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations) ? (
|
|
||||||
<OperationsDisplay operations={input.operations} />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
Object.keys(input).length > 0 ? (
|
|
||||||
<CodeBlock
|
|
||||||
code={JSON.stringify(input, null, 2)}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{output &&
|
|
||||||
state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
||||||
>
|
|
||||||
{isTruncated
|
|
||||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
|
||||||
: output}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{/* Show get_shape_library output on success */}
|
|
||||||
{output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
state === "output-available" &&
|
|
||||||
isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40">
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
Library loaded (
|
|
||||||
{typeof output === "string" ? output.length : 0}{" "}
|
|
||||||
chars)
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
||||||
{typeof output === "string"
|
|
||||||
? output.substring(0, 800) +
|
|
||||||
(output.length > 800 ? "\n..." : "")
|
|
||||||
: String(output)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,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,16 +0,0 @@
|
|||||||
export interface DiagramOperation {
|
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolPartLike {
|
|
||||||
type: string
|
|
||||||
toolCallId: string
|
|
||||||
state?: string
|
|
||||||
input?: {
|
|
||||||
xml?: string
|
|
||||||
operations?: DiagramOperation[]
|
|
||||||
} & Record<string, unknown>
|
|
||||||
output?: string
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { wrapWithMxFile } from "@/lib/utils"
|
import { wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
|
|||||||
interface DevXmlSimulatorProps {
|
interface DevXmlSimulatorProps {
|
||||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||||
onDisplayChart: (xml: string) => void
|
onDisplayChart: (xml: string) => void
|
||||||
onShowQuotaToast?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevXmlSimulator({
|
export function DevXmlSimulator({
|
||||||
setMessages,
|
setMessages,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onShowQuotaToast,
|
|
||||||
}: DevXmlSimulatorProps) {
|
}: DevXmlSimulatorProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const [devXml, setDevXml] = useState("")
|
const [devXml, setDevXml] = useState("")
|
||||||
const [isSimulating, setIsSimulating] = useState(false)
|
const [isSimulating, setIsSimulating] = useState(false)
|
||||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||||
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: dict.dev.simulatingMessage,
|
text: "[Dev] Simulating XML streaming",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
|
|||||||
const lastMsg = updated[updated.length - 1] as any
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
lastMsg.parts[0].state = "output-available"
|
lastMsg.parts[0].state = "output-available"
|
||||||
lastMsg.parts[0].output = dict.dev.successMessage
|
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -247,12 +243,12 @@ export function DevXmlSimulator({
|
|||||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||||
<details>
|
<details>
|
||||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||||
{dict.dev.title}
|
Dev: XML Streaming Simulator
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.preset}
|
Preset:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{dict.dev.selectPreset}
|
Select a preset...
|
||||||
</option>
|
</option>
|
||||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||||
<option key={name} value={name}>
|
<option key={name} value={name}>
|
||||||
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
|
|||||||
onClick={() => setDevXml("")}
|
onClick={() => setDevXml("")}
|
||||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||||
>
|
>
|
||||||
{dict.dev.clear}
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder={dict.dev.placeholder}
|
placeholder="Paste mxCell XML here or select a preset..."
|
||||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
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-4">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.interval}
|
Interval:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.chars}
|
Chars:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -332,8 +328,8 @@ export function DevXmlSimulator({
|
|||||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
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
|
{isSimulating
|
||||||
? dict.dev.streaming
|
? "Streaming..."
|
||||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
>
|
>
|
||||||
{dict.dev.stop}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"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 { useEffect, useRef, useState } from "react"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
|
||||||
@@ -20,19 +20,12 @@ interface FilePreviewListProps {
|
|||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<
|
|
||||||
string,
|
|
||||||
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
|
||||||
>
|
|
||||||
onRemoveUrl?: (url: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreviewList({
|
export function FilePreviewList({
|
||||||
files,
|
files,
|
||||||
onRemoveFile,
|
onRemoveFile,
|
||||||
pdfData = new Map(),
|
pdfData = new Map(),
|
||||||
urlData,
|
|
||||||
onRemoveUrl,
|
|
||||||
}: FilePreviewListProps) {
|
}: FilePreviewListProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
@@ -84,7 +77,7 @@ export function FilePreviewList({
|
|||||||
}
|
}
|
||||||
}, [imageUrls, selectedImage])
|
}, [imageUrls, selectedImage])
|
||||||
|
|
||||||
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
if (files.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -159,59 +152,6 @@ export function FilePreviewList({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
{/* Image Modal/Lightbox */}
|
{/* Image Modal/Lightbox */}
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Image from "next/image"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -43,7 +43,7 @@ export function HistoryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -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,16 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
|
||||||
AlertTriangle,
|
import { useMemo, useState } from "react"
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Monitor,
|
|
||||||
Server,
|
|
||||||
Settings2,
|
|
||||||
User,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
ModelSelectorContent,
|
ModelSelectorContent,
|
||||||
ModelSelectorEmpty,
|
ModelSelectorEmpty,
|
||||||
@@ -21,25 +12,33 @@ import {
|
|||||||
ModelSelectorLogo,
|
ModelSelectorLogo,
|
||||||
ModelSelectorName,
|
ModelSelectorName,
|
||||||
ModelSelector as ModelSelectorRoot,
|
ModelSelector as ModelSelectorRoot,
|
||||||
ModelSelectorSectionHeader,
|
|
||||||
ModelSelectorSeparator,
|
ModelSelectorSeparator,
|
||||||
ModelSelectorTrigger,
|
ModelSelectorTrigger,
|
||||||
} from "@/components/ai-elements/model-selector"
|
} from "@/components/ai-elements/model-selector"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
type FlattenedModel,
|
|
||||||
PROVIDER_LOGO_MAP,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
onSelect: (modelId: string | undefined) => void
|
onSelect: (modelId: string | undefined) => void
|
||||||
onConfigure?: () => void
|
onConfigure: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
showUnvalidatedModels?: boolean
|
}
|
||||||
|
|
||||||
|
// Map our provider names to models.dev logo names
|
||||||
|
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
google: "google",
|
||||||
|
azure: "azure",
|
||||||
|
bedrock: "amazon-bedrock",
|
||||||
|
openrouter: "openrouter",
|
||||||
|
deepseek: "deepseek",
|
||||||
|
siliconflow: "siliconflow",
|
||||||
|
gateway: "vercel",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
@@ -51,11 +50,7 @@ function groupModelsByProvider(
|
|||||||
{ provider: string; models: FlattenedModel[] }
|
{ provider: string; models: FlattenedModel[] }
|
||||||
>()
|
>()
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// For server models, strip "Server · " prefix for cleaner grouping
|
const key = model.providerLabel
|
||||||
const key =
|
|
||||||
model.source === "server"
|
|
||||||
? model.providerLabel.replace(/^Server · /, "")
|
|
||||||
: model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
const existing = groups.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
@@ -72,36 +67,17 @@ export function ModelSelector({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showUnvalidatedModels = false,
|
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
// Filter models based on showUnvalidatedModels setting
|
// Only show validated models in the selector
|
||||||
const displayModels = useMemo(() => {
|
const validatedModels = useMemo(
|
||||||
if (showUnvalidatedModels) {
|
() => models.filter((m) => m.validated === true),
|
||||||
return models
|
[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(
|
const groupedModels = useMemo(
|
||||||
() => displayModels.filter((m) => m.source !== "server"),
|
() => groupModelsByProvider(validatedModels),
|
||||||
[displayModels],
|
[validatedModels],
|
||||||
)
|
|
||||||
|
|
||||||
// Group each category separately
|
|
||||||
const groupedServerModels = useMemo(
|
|
||||||
() => groupModelsByProvider(serverModels),
|
|
||||||
[serverModels],
|
|
||||||
)
|
|
||||||
const groupedUserModels = useMemo(
|
|
||||||
() => groupModelsByProvider(userModels),
|
|
||||||
[userModels],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Find selected model for display
|
// Find selected model for display
|
||||||
@@ -111,7 +87,9 @@ export function ModelSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value === "__server_default__") {
|
if (value === "__configure__") {
|
||||||
|
onConfigure()
|
||||||
|
} else if (value === "__server_default__") {
|
||||||
onSelect(undefined)
|
onSelect(undefined)
|
||||||
} else {
|
} else {
|
||||||
onSelect(value)
|
onSelect(value)
|
||||||
@@ -123,317 +101,122 @@ export function ModelSelector({
|
|||||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||||
: `${dict.modelConfig.usingServerDefault} ${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 (
|
return (
|
||||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
<ModelSelectorTrigger asChild>
|
||||||
<ModelSelectorTrigger asChild>
|
<ButtonWithTooltip
|
||||||
<ButtonWithTooltip
|
tooltipContent={tooltipContent}
|
||||||
tooltipContent={tooltipContent}
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
disabled={disabled}
|
||||||
disabled={disabled}
|
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
|
||||||
className={cn(
|
>
|
||||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
!showLabel && "px-1.5 justify-center",
|
<span className="text-xs truncate">
|
||||||
)}
|
{selectedModel
|
||||||
// accessibility: expose label to screen readers
|
? selectedModel.modelId
|
||||||
aria-label={tooltipContent}
|
: dict.modelConfig.default}
|
||||||
>
|
</span>
|
||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
</ButtonWithTooltip>
|
||||||
{showLabel ? (
|
</ModelSelectorTrigger>
|
||||||
<span className="min-w-0 truncate text-xs">
|
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||||
{selectedModel
|
<ModelSelectorInput
|
||||||
? selectedModel.modelId
|
placeholder={dict.modelConfig.searchModels}
|
||||||
: dict.modelConfig.default}
|
/>
|
||||||
</span>
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
) : (
|
<ModelSelectorEmpty>
|
||||||
// Keep an sr-only label for screen readers when hidden
|
{validatedModels.length === 0 && models.length > 0
|
||||||
<span className="sr-only">
|
? dict.modelConfig.noVerifiedModels
|
||||||
{selectedModel
|
: dict.modelConfig.noModelsFound}
|
||||||
? selectedModel.modelId
|
</ModelSelectorEmpty>
|
||||||
: dict.modelConfig.default}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
</ModelSelectorTrigger>
|
|
||||||
|
|
||||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
{/* Server Default Option */}
|
||||||
<ModelSelectorInput
|
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||||
placeholder={dict.modelConfig.searchModels}
|
<ModelSelectorItem
|
||||||
/>
|
value="__server_default__"
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
onSelect={handleSelect}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
className={cn(
|
||||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
"cursor-pointer",
|
||||||
<ModelSelectorEmpty>
|
!selectedModelId && "bg-accent",
|
||||||
{displayModels.length === 0 &&
|
)}
|
||||||
models.length > 0
|
>
|
||||||
? dict.modelConfig.noVerifiedModels
|
<Check
|
||||||
: dict.modelConfig.noModelsFound}
|
className={cn(
|
||||||
</ModelSelectorEmpty>
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
? "opacity-100"
|
||||||
{serverModels.length === 0 && (
|
: "opacity-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 className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.serverDefault}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
|
||||||
{/* Server Models Section */}
|
{/* Configured Models by Provider */}
|
||||||
{serverModels.length > 0 && (
|
{Array.from(groupedModels.entries()).map(
|
||||||
<>
|
([
|
||||||
<ModelSelectorSectionHeader
|
providerLabel,
|
||||||
icon={<Monitor />}
|
{ provider, models: providerModels },
|
||||||
label={
|
]) => (
|
||||||
dict.modelConfig.serverModels
|
<ModelSelectorGroup
|
||||||
}
|
key={providerLabel}
|
||||||
/>
|
heading={providerLabel}
|
||||||
{Array.from(
|
>
|
||||||
groupedServerModels.entries(),
|
{providerModels.map((model) => (
|
||||||
).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
models: providerModels,
|
|
||||||
},
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`server-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map(
|
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(
|
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.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.modelId
|
|
||||||
}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(
|
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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
|
<ModelSelectorItem
|
||||||
value="__configure_models__"
|
key={model.id}
|
||||||
onSelect={() => {
|
value={model.modelId}
|
||||||
onConfigure()
|
onSelect={() => handleSelect(model.id)}
|
||||||
setOpen(false)
|
className="cursor-pointer"
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<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>
|
<ModelSelectorName>
|
||||||
{dict.modelConfig.configureModels}
|
{model.modelId}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
</div>
|
))}
|
||||||
)}
|
</ModelSelectorGroup>
|
||||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
),
|
||||||
{showUnvalidatedModels
|
)}
|
||||||
? dict.modelConfig.allModelsShown
|
|
||||||
: dict.modelConfig.onlyVerifiedShown}
|
{/* Configure Option */}
|
||||||
</div>
|
<ModelSelectorSeparator />
|
||||||
</div>
|
<ModelSelectorGroup>
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__configure__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.configureModels}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
{/* Info text */}
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
|
{dict.modelConfig.onlyVerifiedShown}
|
||||||
</div>
|
</div>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorList>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorContent>
|
||||||
</div>
|
</ModelSelectorRoot>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +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 { 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)
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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>
|
|
||||||
{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)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
info.defaultBaseUrl ||
|
|
||||||
dict.modelConfig.customEndpoint
|
|
||||||
}
|
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
|
||||||
/>
|
|
||||||
{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,6 +1,7 @@
|
|||||||
"use client"
|
"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 type React from "react"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
@@ -11,7 +12,6 @@ interface QuotaLimitToastProps {
|
|||||||
used: number
|
used: number
|
||||||
limit: number
|
limit: number
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
onConfigModel?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuotaLimitToast({
|
export function QuotaLimitToast({
|
||||||
@@ -19,26 +19,12 @@ export function QuotaLimitToast({
|
|||||||
used,
|
used,
|
||||||
limit,
|
limit,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
onConfigModel,
|
|
||||||
}: QuotaLimitToastProps) {
|
}: QuotaLimitToastProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isTokenLimit = type === "token"
|
const isTokenLimit = type === "token"
|
||||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
|
||||||
const formatNumber = (n: number) =>
|
const formatNumber = (n: number) =>
|
||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
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) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -84,63 +70,34 @@ export function QuotaLimitToast({
|
|||||||
</div>
|
</div>
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||||
<p>{quotaMessage}</p>
|
<p>
|
||||||
{!isSelfHosted && (
|
{isTokenLimit
|
||||||
<p
|
? dict.quota.messageToken
|
||||||
dangerouslySetInnerHTML={{
|
: dict.quota.messageApi}
|
||||||
__html: formatMessage(
|
</p>
|
||||||
dict.quota.doubaoSponsorship,
|
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
||||||
{
|
|
||||||
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>
|
<p>{dict.quota.reset}</p>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onConfigModel && (
|
<a
|
||||||
<button
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
type="button"
|
target="_blank"
|
||||||
onClick={() => {
|
rel="noopener noreferrer"
|
||||||
onConfigModel()
|
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"
|
||||||
onDismiss()
|
>
|
||||||
}}
|
<FaGithub className="w-3.5 h-3.5" />
|
||||||
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"
|
{dict.quota.selfHost}
|
||||||
>
|
</a>
|
||||||
<Settings className="w-3.5 h-3.5" />
|
<a
|
||||||
{dict.quota.configModel}
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
</button>
|
target="_blank"
|
||||||
)}
|
rel="noopener noreferrer"
|
||||||
{!isSelfHosted && (
|
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"
|
||||||
<>
|
>
|
||||||
<a
|
<Coffee className="w-3.5 h-3.5" />
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
{dict.quota.sponsor}
|
||||||
target="_blank"
|
</a>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<FaGithub className="w-3.5 h-3.5" />
|
|
||||||
{dict.quota.selfHost}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<Coffee className="w-3.5 h-3.5" />
|
|
||||||
{dict.quota.sponsor}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
export type ExportFormat = "drawio" | "png" | "svg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,11 +74,6 @@ export function SaveDialog({
|
|||||||
label: dict.save.formats.svg,
|
label: dict.save.formats.svg,
|
||||||
extension: ".svg",
|
extension: ".svg",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "xmlsvg" as const,
|
|
||||||
label: dict.save.formats.xmlsvg,
|
|
||||||
extension: ".drawio.svg",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,12 +21,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// Reusable setting item component for consistent layout
|
// Reusable setting item component for consistent layout
|
||||||
function SettingItem({
|
function SettingItem({
|
||||||
@@ -58,28 +55,20 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
en: "English",
|
en: "English",
|
||||||
zh: "中文",
|
zh: "中文",
|
||||||
ja: "日本語",
|
ja: "日本語",
|
||||||
"zh-Hant": "繁體中文",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
drawioUi: DrawioTheme
|
onCloseProtectionChange?: (enabled: boolean) => void
|
||||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
drawioUi: "min" | "sketch"
|
||||||
|
onToggleDrawioUi: () => void
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
minimalStyle?: boolean
|
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
|
||||||
vlmValidationEnabled?: boolean
|
|
||||||
onVlmValidationChange?: (value: boolean) => void
|
|
||||||
onOpenModelConfig?: () => void
|
|
||||||
customSystemMessage?: string
|
|
||||||
onCustomSystemMessageChange?: (value: string) => void
|
|
||||||
maxOutputTokens?: string
|
|
||||||
onMaxOutputTokensChange?: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
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"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
@@ -92,58 +81,28 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onCloseProtectionChange,
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onDrawioUiChange,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
|
||||||
onMinimalStyleChange = () => {},
|
|
||||||
vlmValidationEnabled = false,
|
|
||||||
onVlmValidationChange = () => {},
|
|
||||||
onOpenModelConfig,
|
|
||||||
customSystemMessage = "",
|
|
||||||
onCustomSystemMessageChange = () => {},
|
|
||||||
maxOutputTokens = "",
|
|
||||||
onMaxOutputTokensChange = () => {},
|
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const search = useSearchParams()
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
|
const [closeProtection, setCloseProtection] = useState(true)
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
|
|
||||||
// Panel visibility state
|
|
||||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
|
||||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
|
||||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
|
||||||
|
|
||||||
const handlePanelToggle = useCallback(
|
|
||||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
|
||||||
setter(value)
|
|
||||||
localStorage.setItem(key, String(value))
|
|
||||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Proxy settings state (Electron only)
|
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
|
||||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
// Only fetch if not cached in localStorage
|
||||||
// the access code input if the server requires it. This fixes the case
|
if (getStoredAccessCodeRequired() !== null) return
|
||||||
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
|
||||||
// configured) would hide the access code input.
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch(getApiEndpoint("/api/config"))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -159,9 +118,10 @@ function SettingsContent({
|
|||||||
setAccessCodeRequired(required)
|
setAccessCodeRequired(required)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Keep existing cached value on error
|
// Don't cache on error - allow retry on next mount
|
||||||
|
setAccessCodeRequired(false)
|
||||||
})
|
})
|
||||||
}, [open])
|
}, [])
|
||||||
|
|
||||||
// Detect current language from pathname
|
// Detect current language from pathname
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,45 +140,17 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedSendShortcut = localStorage.getItem(
|
const storedCloseProtection = localStorage.getItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
)
|
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
|
||||||
|
|
||||||
setShowRecentChats(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
|
||||||
)
|
|
||||||
setShowMyTemplates(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
|
||||||
)
|
|
||||||
setShowQuickExamples(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
|
||||||
"false",
|
|
||||||
)
|
)
|
||||||
|
// Default to true if not set
|
||||||
|
setCloseProtection(storedCloseProtection !== "false")
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
|
||||||
if (window.electronAPI?.getProxy) {
|
|
||||||
window.electronAPI.getProxy().then((config) => {
|
|
||||||
setHttpProxy(config.httpProxy || "")
|
|
||||||
setHttpsProxy(config.httpsProxy || "")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const changeLanguage = (lang: string) => {
|
const changeLanguage = (lang: string) => {
|
||||||
// Save locale to localStorage for persistence across restarts
|
|
||||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
|
||||||
|
|
||||||
// Notify Electron main process to update its menu language
|
|
||||||
if (window.electronAPI?.setUserLocale) {
|
|
||||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
|
||||||
console.error("Failed to sync locale with Electron:", error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = pathname.split("/")
|
const parts = pathname.split("/")
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
parts[1] = lang
|
parts[1] = lang
|
||||||
@@ -270,48 +202,8 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyProxy = async () => {
|
|
||||||
if (!window.electronAPI?.setProxy) return
|
|
||||||
|
|
||||||
// Validate proxy URLs (must start with http:// or https://)
|
|
||||||
const validateProxyUrl = (url: string): boolean => {
|
|
||||||
if (!url) return true // Empty is OK
|
|
||||||
return url.startsWith("http://") || url.startsWith("https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedHttp = httpProxy.trim()
|
|
||||||
const trimmedHttps = httpsProxy.trim()
|
|
||||||
|
|
||||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
|
||||||
toast.error("HTTP Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
|
||||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsApplyingProxy(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.setProxy({
|
|
||||||
httpProxy: trimmedHttp || undefined,
|
|
||||||
httpsProxy: trimmedHttps || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(dict.settings.proxyApplied)
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to apply proxy settings")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to apply proxy settings")
|
|
||||||
} finally {
|
|
||||||
setIsApplyingProxy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
@@ -321,29 +213,8 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
<div className="px-6 pb-6">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{/* API Keys & Models */}
|
|
||||||
{onOpenModelConfig && (
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.apiKeysModels}
|
|
||||||
description={dict.settings.apiKeysModelsDescription}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenChange(false)
|
|
||||||
onOpenModelConfig()
|
|
||||||
}}
|
|
||||||
aria-label={dict.settings.apiKeysModels}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Access Code (conditional) */}
|
{/* Access Code (conditional) */}
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<div className="py-4 first:pt-0 space-y-3">
|
<div className="py-4 first:pt-0 space-y-3">
|
||||||
@@ -437,302 +308,51 @@ function SettingsContent({
|
|||||||
{/* Draw.io Style */}
|
{/* Draw.io Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.drawioStyle}
|
label={dict.settings.drawioStyle}
|
||||||
description={dict.settings.drawioStyleDescription}
|
description={`${dict.settings.drawioStyleDescription} ${
|
||||||
|
drawioUi === "min"
|
||||||
|
? dict.settings.minimal
|
||||||
|
: dict.settings.sketch
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Select
|
<Button
|
||||||
value={drawioUi}
|
id="drawio-ui"
|
||||||
onValueChange={(v) =>
|
variant="outline"
|
||||||
onDrawioUiChange(v as DrawioTheme)
|
onClick={onToggleDrawioUi}
|
||||||
}
|
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
{dict.settings.switchTo}{" "}
|
||||||
id="drawio-ui-select"
|
{drawioUi === "min"
|
||||||
aria-label={dict.settings.drawioStyle}
|
? dict.settings.sketch
|
||||||
className="w-[120px] h-9 rounded-xl"
|
: dict.settings.minimal}
|
||||||
>
|
</Button>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="kennedy">
|
|
||||||
{dict.settings.themeDefault}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="atlas">Atlas</SelectItem>
|
|
||||||
<SelectItem value="dark">
|
|
||||||
{dict.settings.themeDark}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="min">
|
|
||||||
{dict.settings.themeMinimal}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="sketch">
|
|
||||||
{dict.settings.themeSketch}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="simple">
|
|
||||||
{dict.settings.themeSimple}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Close Protection */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramStyle}
|
label={dict.settings.closeProtection}
|
||||||
description={dict.settings.diagramStyleDescription}
|
description={dict.settings.closeProtectionDescription}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Switch
|
||||||
<Switch
|
id="close-protection"
|
||||||
id="minimal-style"
|
checked={closeProtection}
|
||||||
checked={minimalStyle}
|
onCheckedChange={(checked) => {
|
||||||
onCheckedChange={onMinimalStyleChange}
|
setCloseProtection(checked)
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{minimalStyle
|
|
||||||
? dict.chat.minimalStyle
|
|
||||||
: dict.chat.styledMode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Panel Visibility */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.panelVisibility}
|
|
||||||
description={dict.settings.panelVisibilityDescription}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-recent-chats"
|
|
||||||
checked={showRecentChats}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showRecentChats,
|
|
||||||
v,
|
|
||||||
setShowRecentChats,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showRecentChats}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-my-templates"
|
|
||||||
checked={showMyTemplates}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showMyTemplates,
|
|
||||||
v,
|
|
||||||
setShowMyTemplates,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showMyTemplates}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-quick-examples"
|
|
||||||
checked={showQuickExamples}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showQuickExamples,
|
|
||||||
v,
|
|
||||||
setShowQuickExamples,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showQuickExamples}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* VLM Diagram Validation */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.diagramValidation}
|
|
||||||
description={dict.settings.diagramValidationDescription}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="vlm-validation"
|
|
||||||
checked={vlmValidationEnabled}
|
|
||||||
onCheckedChange={onVlmValidationChange}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{vlmValidationEnabled
|
|
||||||
? dict.settings.enabled
|
|
||||||
: dict.settings.disabled}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Custom System Message */}
|
|
||||||
<div className="py-4 space-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="custom-system-message"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{dict.settings.customSystemMessage}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.customSystemMessageDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id="custom-system-message"
|
|
||||||
value={customSystemMessage}
|
|
||||||
onChange={(e) =>
|
|
||||||
onCustomSystemMessageChange(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
dict.settings.customSystemMessagePlaceholder
|
|
||||||
}
|
|
||||||
className="min-h-[80px] max-h-[160px] text-sm"
|
|
||||||
maxLength={5000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Output Tokens */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.maxOutputTokens}
|
|
||||||
description={dict.settings.maxOutputTokensDescription}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="max-output-tokens"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={maxOutputTokens}
|
|
||||||
onChange={(e) =>
|
|
||||||
onMaxOutputTokensChange(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="64000"
|
|
||||||
className="h-9 w-28 text-sm"
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.sendShortcut}
|
|
||||||
description={dict.settings.sendShortcutDescription}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={sendShortcut}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSendShortcut(value)
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
value,
|
checked.toString(),
|
||||||
)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("sendShortcutChange", {
|
|
||||||
detail: value,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
onCloseProtectionChange?.(checked)
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<SelectTrigger
|
|
||||||
id="send-shortcut-select"
|
|
||||||
className="w-auto h-9 rounded-xl"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="enter">
|
|
||||||
{dict.settings.enterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ctrl-enter">
|
|
||||||
{dict.settings.ctrlEnterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Proxy Settings - Electron only */}
|
|
||||||
{typeof window !== "undefined" &&
|
|
||||||
window.electronAPI?.isElectron && (
|
|
||||||
<div className="py-4 space-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
{dict.settings.proxy}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.proxyDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
id="http-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="https-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpsProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpsProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleApplyProxy}
|
|
||||||
disabled={isApplyingProxy}
|
|
||||||
className="h-9 px-4 rounded-xl w-full"
|
|
||||||
>
|
|
||||||
{isApplyingProxy
|
|
||||||
? "..."
|
|
||||||
: dict.settings.applyProxy}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
Version {process.env.APP_VERSION}
|
||||||
<Tag className="h-3 w-3" />
|
</p>
|
||||||
{process.env.APP_VERSION}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">·</span>
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Github className="h-3 w-3" />
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
|
||||||
"true" && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">·</span>
|
|
||||||
<a
|
|
||||||
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{dict.nav.about}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
)
|
)
|
||||||
|
|||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -77,13 +77,12 @@ function CommandInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
function CommandList({
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,37 +2,29 @@
|
|||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { toast } from "sonner"
|
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||||
extractDiagramXML,
|
|
||||||
isRealDiagram,
|
|
||||||
validateAndFixXml,
|
|
||||||
} from "../lib/utils"
|
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>
|
||||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: EventExport) => void
|
handleDiagramExport: (data: any) => void
|
||||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
|
||||||
) => void
|
) => void
|
||||||
getThumbnailSvg: () => Promise<string | null>
|
saveDiagramToStorage: () => Promise<void>
|
||||||
captureValidationPng: () => Promise<string | null>
|
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -49,41 +41,75 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{ svg: string; xml: string }[]
|
{ svg: string; xml: string }[]
|
||||||
>([])
|
>([])
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
|
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
// Resolver for PNG export (used for VLM validation)
|
|
||||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track if diagram has been restored from localStorage
|
||||||
const chartXMLRef = useRef<string>("")
|
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||||
|
|
||||||
const onDrawioLoad = () => {
|
const onDrawioLoad = () => {
|
||||||
// Only set ready state once to prevent infinite loops
|
// Only set ready state once to prevent infinite loops
|
||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
|
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
||||||
setIsDrawioReady(true)
|
setIsDrawioReady(true)
|
||||||
// Restore diagram after remount (e.g., theme/UI change)
|
|
||||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
|
||||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDrawioReady = () => {
|
const resetDrawioReady = () => {
|
||||||
|
// console.log("[DiagramContext] Resetting DrawIO ready state")
|
||||||
hasCalledOnLoadRef.current = false
|
hasCalledOnLoadRef.current = false
|
||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXMLRef in sync with state for restoration after remount
|
// Restore diagram XML when DrawIO becomes ready
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
||||||
}, [chartXML])
|
if (!isDrawioReady) {
|
||||||
|
hasDiagramRestoredRef.current = false
|
||||||
|
setCanSaveDiagram(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasDiagramRestoredRef.current) return
|
||||||
|
hasDiagramRestoredRef.current = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedDiagramXml = localStorage.getItem(
|
||||||
|
STORAGE_DIAGRAM_XML_KEY,
|
||||||
|
)
|
||||||
|
if (savedDiagramXml) {
|
||||||
|
// Skip validation for trusted saved diagrams
|
||||||
|
loadDiagram(savedDiagramXml, true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restore diagram from localStorage:", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow saving after restore is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
setCanSaveDiagram(true)
|
||||||
|
}, 500)
|
||||||
|
}, [isDrawioReady])
|
||||||
|
|
||||||
|
// Save diagram XML to localStorage whenever it changes (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSaveDiagram) return
|
||||||
|
if (!chartXML || chartXML.length <= 300) return
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [chartXML, canSaveDiagram])
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
resolver: ((data: string) => void) | null
|
||||||
format: ExportFormat | null
|
format: ExportFormat | null
|
||||||
}>({ resolver: null, format: null })
|
}>({ resolver: null, format: null })
|
||||||
|
|
||||||
@@ -106,63 +132,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
// Save current diagram to localStorage (used before theme/UI changes)
|
||||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
const saveDiagramToStorage = async (): Promise<void> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return
|
||||||
// Don't export if diagram is empty
|
|
||||||
if (!isRealDiagram(chartXML)) return null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const svgData = await Promise.race([
|
const currentXml = await Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
setTimeout(() => reject(new Error("Export timeout")), 2000),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Update latestSvg so it's available for future saves
|
// Only save if diagram has meaningful content (not empty template)
|
||||||
if (svgData?.includes("<svg")) {
|
if (currentXml && currentXml.length > 300) {
|
||||||
setLatestSvg(svgData)
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
||||||
return svgData
|
|
||||||
}
|
}
|
||||||
return null
|
} catch (error) {
|
||||||
} catch {
|
console.error("Failed to save diagram to storage:", error)
|
||||||
// Timeout is expected occasionally - don't log as error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
|
||||||
const captureValidationPng = async (): Promise<string | null> => {
|
|
||||||
if (!drawioRef.current) return null
|
|
||||||
// Don't export if diagram is empty
|
|
||||||
if (!isRealDiagram(chartXML)) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pngData = await Promise.race([
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
pngResolverRef.current = resolve
|
|
||||||
drawioRef.current?.exportDiagram({ format: "png" })
|
|
||||||
}),
|
|
||||||
new Promise<string>((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("PNG export timeout")),
|
|
||||||
5000,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
// PNG data should be a base64 data URL
|
|
||||||
if (pngData?.startsWith("data:image/png")) {
|
|
||||||
return pngData
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
// Timeout is expected occasionally - don't log as error
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,32 +194,21 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: EventExport) => {
|
const handleDiagramExport = (data: any) => {
|
||||||
// Handle PNG export for VLM validation
|
|
||||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
|
||||||
pngResolverRef.current(data.data)
|
|
||||||
pngResolverRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Handle save to file if requested (process raw data before extraction)
|
||||||
if (saveResolverRef.current.resolver) {
|
if (saveResolverRef.current.resolver) {
|
||||||
const format = saveResolverRef.current.format
|
const format = saveResolverRef.current.format
|
||||||
saveResolverRef.current.resolver(data.data, data.xml)
|
saveResolverRef.current.resolver(data.data)
|
||||||
saveResolverRef.current = { resolver: null, format: null }
|
saveResolverRef.current = { resolver: null, format: null }
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
if (format === "png" || format === "svg") {
|
||||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
|
||||||
return
|
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)
|
const extractedXML = extractDiagramXML(data.data)
|
||||||
|
setChartXML(extractedXML)
|
||||||
setLatestSvg(data.data)
|
setLatestSvg(data.data)
|
||||||
|
|
||||||
// Only add to history if this was a user-initiated export
|
// Only add to history if this was a user-initiated export
|
||||||
@@ -256,16 +235,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
|
||||||
if (!data?.xml) return
|
|
||||||
// Don't overwrite a pending restore - if we have a real diagram in state
|
|
||||||
// but DrawIO isn't ready yet, it means we're waiting to restore
|
|
||||||
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setChartXML(data.xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -278,7 +247,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
|
||||||
) => {
|
) => {
|
||||||
if (!drawioRef.current) {
|
if (!drawioRef.current) {
|
||||||
console.warn("Draw.io editor not ready")
|
console.warn("Draw.io editor not ready")
|
||||||
@@ -286,21 +254,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat =
|
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
resolver: (exportData: string) => {
|
||||||
let fileContent: string | Blob
|
let fileContent: string | Blob
|
||||||
let mimeType: string
|
let mimeType: string
|
||||||
let extension: string
|
let extension: string
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Prefer the complete document from the export event so all pages are saved.
|
// Extract XML from SVG for .drawio format
|
||||||
const xml = fullDiagramXML?.trim()
|
const xml = extractDiagramXML(exportData)
|
||||||
? fullDiagramXML
|
|
||||||
: extractDiagramXML(exportData)
|
|
||||||
let xmlContent = xml
|
let xmlContent = xml
|
||||||
if (!xml.includes("<mxfile")) {
|
if (!xml.includes("<mxfile")) {
|
||||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||||
@@ -308,18 +273,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = xmlContent
|
fileContent = xmlContent
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
|
|
||||||
|
// Save to localStorage when user manually saves
|
||||||
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
} else if (format === "xmlsvg") {
|
|
||||||
// Editable SVG: pass data URL directly (like PNG)
|
|
||||||
fileContent = exportData
|
|
||||||
mimeType = "image/svg+xml"
|
|
||||||
extension = ".drawio.svg"
|
|
||||||
} else {
|
} else {
|
||||||
// SVG format (view-only)
|
// SVG format
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
@@ -348,14 +311,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
// Show success toast after download is initiated
|
|
||||||
if (successMessage) {
|
|
||||||
toast.success(successMessage, {
|
|
||||||
position: "bottom-left",
|
|
||||||
duration: 2500,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
@@ -391,18 +346,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXML,
|
chartXML,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
setDiagramHistory,
|
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
saveDiagramToStorage,
|
||||||
captureValidationPng,
|
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
environment:
|
||||||
# Persists admin panel settings (data/settings.json)
|
# For subdirectory deployment, uncomment and set your path:
|
||||||
- ./data:/app/data
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
# environment:
|
|
||||||
# # For subdirectory deployment, uncomment and set your path:
|
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
281
docs/README_CN.md
Normal file
281
docs/README_CN.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# 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)
|
||||||
|
- [目录](#目录)
|
||||||
|
- [示例](#示例)
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [MCP服务器(预览)](#mcp服务器预览)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [在线试用](#在线试用)
|
||||||
|
- [使用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)
|
||||||
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
|
## MCP服务器(预览)
|
||||||
|
|
||||||
|
> **预览功能**:此功能为实验性功能,可能会有变化。
|
||||||
|
|
||||||
|
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
然后让Claude创建图表:
|
||||||
|
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
|
||||||
|
|
||||||
|
图表会实时显示在浏览器中!
|
||||||
|
|
||||||
|
详情请参阅[MCP服务器README](../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 在线试用
|
||||||
|
|
||||||
|
无需安装!直接在我们的演示站点试用:
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|
||||||
|
---
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||||
|
|
||||||
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
[English](../README.md) | [中文](./README_CN.md) | 日本語
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -13,14 +13,12 @@
|
|||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||||
|
|
||||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
## 目次
|
## 目次
|
||||||
@@ -28,21 +26,16 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目次](#目次)
|
- [目次](#目次)
|
||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー](#mcpサーバー)
|
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
- [デスクトップアプリケーション](#デスクトップアプリケーション)
|
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
||||||
- [Dockerで実行](#dockerで実行)
|
|
||||||
- [インストール](#インストール)
|
- [インストール](#インストール)
|
||||||
- [デプロイ](#デプロイ)
|
- [デプロイ](#デプロイ)
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
|
||||||
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
|
- [プロジェクト構造](#プロジェクト構造)
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||||
- [よくある質問](#よくある質問)
|
|
||||||
- [スター履歴](#スター履歴)
|
- [スター履歴](#スター履歴)
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
@@ -54,32 +47,32 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG技術ダイアグラム</strong><br />
|
<strong>GCPアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
<img src="../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>ReactとAWSによる認証</strong><br />
|
<strong>AWSアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
<img src="../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>オープンイノベーション</strong><br />
|
<strong>Azureアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
<img src="../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -96,7 +89,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
## MCPサーバー
|
## MCPサーバー(プレビュー)
|
||||||
|
|
||||||
|
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||||
|
|
||||||
@@ -122,7 +117,7 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||||
|
|
||||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
|
|
||||||
@@ -130,19 +125,41 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
インストール不要!デモサイトで直接お試しください:
|
インストール不要!デモサイトで直接お試しください:
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
|
> 注意:アクセス数が多いため、デモサイトでは現在 minimax-m2 モデルを使用しています。最高の結果を得るには、Claude Sonnet 4.5 または Claude Opus 4.5 でのセルフホスティングをお勧めします。
|
||||||
|
|
||||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||||
|
|
||||||
### デスクトップアプリケーション
|
### Dockerで実行(推奨)
|
||||||
|
|
||||||
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
|
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
|
||||||
|
|
||||||
対応プラットフォーム:Windows、macOS、Linux。
|
まず、Dockerをインストールしていない場合はインストールしてください:[Dockerを入手](https://docs.docker.com/get-docker/)
|
||||||
|
|
||||||
### Dockerで実行
|
次に実行:
|
||||||
|
|
||||||
[Docker ガイドを参照](./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
|
||||||
|
```
|
||||||
|
|
||||||
|
または 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) で設定オプションをご確認ください。
|
||||||
|
|
||||||
### インストール
|
### インストール
|
||||||
|
|
||||||
@@ -151,82 +168,73 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 依存関係をインストール:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. AIプロバイダーを設定:
|
||||||
|
|
||||||
|
ルートディレクトリに`.env.local`ファイルを作成:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.example .env.local
|
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)を参照してください。
|
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||||
|
|
||||||
2. 開発サーバーを起動:
|
4. 開発サーバーを起動:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
|
||||||
|
|
||||||
## デプロイ
|
## デプロイ
|
||||||
|
|
||||||
### EdgeOne Pagesへのデプロイ
|
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
|
||||||
|
|
||||||
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
|
|
||||||
|
|
||||||
このボタンでデプロイ:
|
|
||||||
|
|
||||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
|
||||||
|
|
||||||
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。
|
|
||||||
|
|
||||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
|
||||||
|
|
||||||
### Vercelへのデプロイ
|
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
|
||||||
|
|
||||||
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
|
||||||
|
|
||||||
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
||||||
|
|
||||||
### Cloudflare Workersへのデプロイ
|
または、このボタンでデプロイできます:
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
|
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
||||||
|
|
||||||
|
|
||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- [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(デフォルト)
|
- AWS Bedrock(デフォルト)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
|
|
||||||
### 管理パネル
|
|
||||||
|
|
||||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
|
||||||
|
|
||||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
@@ -239,21 +247,33 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
ダイアグラムは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/ # サンプル画像を含む静的アセット
|
||||||
|
```
|
||||||
|
|
||||||
## サポート&お問い合わせ
|
## サポート&お問い合わせ
|
||||||
|
|
||||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
|
||||||
|
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||||
|
|
||||||
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
|
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
|
||||||
|
|
||||||
- メール:me[at]jiang.jp
|
- メール:me[at]jiang.jp
|
||||||
|
|
||||||
## よくある質問
|
|
||||||
|
|
||||||
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
|
|
||||||
|
|
||||||
## スター履歴
|
## スター履歴
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
214
docs/ai-providers.md
Normal file
214
docs/ai-providers.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vercel AI Gateway
|
||||||
|
|
||||||
|
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
||||||
|
|
||||||
|
**Basic Usage (Vercel-hosted Gateway):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_GATEWAY_API_KEY=your_gateway_api_key
|
||||||
|
AI_MODEL=openai/gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Gateway URL (for local development or self-hosted Gateway):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_GATEWAY_API_KEY=your_custom_api_key
|
||||||
|
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
||||||
|
AI_MODEL=openai/gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
Model format uses `provider/model` syntax:
|
||||||
|
|
||||||
|
- `openai/gpt-4o` - OpenAI GPT-4o
|
||||||
|
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
||||||
|
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
||||||
|
|
||||||
|
**Configuration notes:**
|
||||||
|
|
||||||
|
- If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used
|
||||||
|
- Custom base URL is useful for:
|
||||||
|
- Local development with a custom Gateway instance
|
||||||
|
- Self-hosted AI Gateway deployments
|
||||||
|
- Enterprise proxy configurations
|
||||||
|
- When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`
|
||||||
|
|
||||||
|
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
||||||
|
|
||||||
|
## Auto-Detection
|
||||||
|
|
||||||
|
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
|
|
||||||
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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,78 +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 地址"
|
|
||||||
|
|
||||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
|
||||||
|
|
||||||
**解决方案**: 必须在构建时通过 `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
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# Next AI Draw.io
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
|
||||||
|
|
||||||
[English](../../README.md) | 中文 | [日本語](../ja/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辅助可视化来创建、修改和增强图表。
|
|
||||||
|
|
||||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
|
|
||||||
|
|
||||||
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
- [Next AI Draw.io](#next-ai-drawio)
|
|
||||||
- [目录](#目录)
|
|
||||||
- [示例](#示例)
|
|
||||||
- [功能特性](#功能特性)
|
|
||||||
- [MCP服务器](#mcp服务器)
|
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [快速开始](#快速开始)
|
|
||||||
- [在线试用](#在线试用)
|
|
||||||
- [桌面应用](#桌面应用)
|
|
||||||
- [使用Docker运行](#使用docker运行)
|
|
||||||
- [安装](#安装)
|
|
||||||
- [部署](#部署)
|
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
|
||||||
- [部署到Vercel](#部署到vercel)
|
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
|
||||||
- [多提供商支持](#多提供商支持)
|
|
||||||
- [工作原理](#工作原理)
|
|
||||||
- [支持与联系](#支持与联系)
|
|
||||||
- [常见问题](#常见问题)
|
|
||||||
- [Star历史](#star历史)
|
|
||||||
|
|
||||||
## 示例
|
|
||||||
|
|
||||||
以下是一些示例提示词及其生成的图表:
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<table width="100%">
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" valign="top" align="center">
|
|
||||||
<strong>动画Transformer连接器</strong><br />
|
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%" valign="top">
|
|
||||||
<strong>RAG技术图</strong><br />
|
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
|
||||||
</td>
|
|
||||||
<td width="50%" valign="top">
|
|
||||||
<strong>React和AWS认证流程</strong><br />
|
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
|
||||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%" valign="top">
|
|
||||||
<strong>开放式创新</strong><br />
|
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
|
||||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
|
||||||
</td>
|
|
||||||
<td width="50%" valign="top">
|
|
||||||
<strong>猫咪素描</strong><br />
|
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</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)
|
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
|
||||||
|
|
||||||
## MCP服务器
|
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
然后让Claude创建图表:
|
|
||||||
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
|
|
||||||
|
|
||||||
图表会实时显示在浏览器中!
|
|
||||||
|
|
||||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 在线试用
|
|
||||||
|
|
||||||
无需安装!直接在我们的演示站点试用:
|
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
|
||||||
|
|
||||||
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
|
||||||
|
|
||||||
### 桌面应用
|
|
||||||
|
|
||||||
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
|
|
||||||
|
|
||||||
支持的平台:Windows、macOS、Linux。
|
|
||||||
|
|
||||||
### 使用Docker运行
|
|
||||||
|
|
||||||
[查看 Docker 指南](./docker.md)
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
1. 克隆仓库:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
|
||||||
cd next-ai-draw-io
|
|
||||||
npm install
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
|
||||||
|
|
||||||
2. 运行开发服务器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。
|
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
### 部署到腾讯云EdgeOne Pages
|
|
||||||
|
|
||||||
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
|
|
||||||
|
|
||||||
直接点击此按钮一键部署:
|
|
||||||
[](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
|
||||||
|
|
||||||
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。
|
|
||||||
|
|
||||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
|
||||||
|
|
||||||
### 部署到Vercel
|
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
|
||||||
|
|
||||||
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
|
||||||
|
|
||||||
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
|
|
||||||
|
|
||||||
### 部署到Cloudflare Workers
|
|
||||||
|
|
||||||
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
|
|
||||||
|
|
||||||
|
|
||||||
## 多提供商支持
|
|
||||||
|
|
||||||
- [字节跳动豆包](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(默认)
|
|
||||||
- OpenAI
|
|
||||||
- Anthropic
|
|
||||||
- Google AI
|
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
|
||||||
- Ollama
|
|
||||||
- OpenRouter
|
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
|
||||||
- SiliconFlow
|
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
|
||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
|
||||||
|
|
||||||
### 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
|
||||||
|
|
||||||
### 管理面板
|
|
||||||
|
|
||||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
|
||||||
|
|
||||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
本应用使用以下技术:
|
|
||||||
|
|
||||||
- **Next.js**:用于前端框架和路由
|
|
||||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
|
|
||||||
- **react-drawio**:用于图表表示和操作
|
|
||||||
|
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
|
||||||
|
|
||||||
|
|
||||||
## 支持与联系
|
|
||||||
|
|
||||||
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
|
||||||
|
|
||||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
|
||||||
|
|
||||||
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
|
|
||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
|
||||||
|
|
||||||
## Star历史
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 管理面板
|
|
||||||
|
|
||||||
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
|
|
||||||
|
|
||||||
## 启用面板
|
|
||||||
|
|
||||||
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
|
||||||
2. 访问 `/admin` 并登录。
|
|
||||||
|
|
||||||
## 可配置内容
|
|
||||||
|
|
||||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
|
||||||
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
|
||||||
|
|
||||||
## 优先级
|
|
||||||
|
|
||||||
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
|
||||||
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
|
||||||
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
|
||||||
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
# AI 提供商配置
|
|
||||||
|
|
||||||
本指南介绍如何为 next-ai-draw-io 配置不同的 AI 模型提供商。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
1. 将 `.env.example` 复制为 `.env.local`
|
|
||||||
2. 设置所选提供商的 API 密钥
|
|
||||||
3. 将 `AI_MODEL` 设置为所需的模型
|
|
||||||
4. 运行 `npm run dev`
|
|
||||||
|
|
||||||
## 支持的提供商
|
|
||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
|
||||||
|
|
||||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(用于 OpenAI 兼容服务):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### SiliconFlow (OpenAI 兼容)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # 示例;使用任何 SiliconFlow 模型 ID
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(默认为推荐域名):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # 或 https://api.siliconflow.cn/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_RESOURCE_NAME=your-resource-name # 必填:您的 Azure 资源名称
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用自定义端点代替资源名称:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME 的替代方案
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的推理配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_REASONING_EFFORT=low # 可选:low, medium, high
|
|
||||||
AZURE_REASONING_SUMMARY=detailed # 可选: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
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (本地)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义 URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
|
||||||
|
|
||||||
Vercel AI Gateway 通过单个 API 密钥提供对多个 AI 提供商的统一访问。这简化了身份验证,让您无需管理多个 API 密钥即可在不同提供商之间切换。
|
|
||||||
|
|
||||||
**基本用法(Vercel 托管网关):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
**自定义网关 URL(用于本地开发或自托管网关):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_custom_api_key
|
|
||||||
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
模型格式使用 `provider/model` 语法:
|
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
|
||||||
|
|
||||||
**配置说明:**
|
|
||||||
|
|
||||||
- 如果未设置 `AI_GATEWAY_BASE_URL`,则使用默认的 Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`)
|
|
||||||
- 自定义基础 URL 适用于:
|
|
||||||
- 使用自定义网关实例进行本地开发
|
|
||||||
- 自托管 AI Gateway 部署
|
|
||||||
- 企业代理配置
|
|
||||||
- 当使用自定义基础 URL 时,必须同时提供 `AI_GATEWAY_API_KEY`
|
|
||||||
|
|
||||||
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax 支持两种 API 格式:
|
|
||||||
- **Anthropic 兼容**(`/anthropic` 端点)— 推荐,支持 interleaved thinking
|
|
||||||
- **OpenAI 兼容**(`/v1` 端点)— 标准 OpenAI 聊天补全格式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
可选配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 中国大陆版,Anthropic 兼容(默认)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# 中国大陆版,OpenAI 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# 国际版,Anthropic 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# 国际版,OpenAI 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (智谱 AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (阿里云通义千问)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (月之暗面 Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (七牛云)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (小米)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自动检测
|
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
|
||||||
|
|
||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
|
||||||
```
|
|
||||||
|
|
||||||
## 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
|
|
||||||
|
|
||||||
### 配置方式
|
|
||||||
|
|
||||||
**方式一:环境变量**(推荐用于云部署)
|
|
||||||
|
|
||||||
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:配置文件**
|
|
||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
|
||||||
|
|
||||||
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
|
|
||||||
|
|
||||||
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字段说明
|
|
||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
|
|
||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
|
||||||
|
|
||||||
### 说明
|
|
||||||
|
|
||||||
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
|
|
||||||
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"` 但 `apiKeyEnv` 不同)。
|
|
||||||
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
|
|
||||||
|
|
||||||
## 模型能力要求
|
|
||||||
|
|
||||||
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
|
||||||
|
|
||||||
**推荐模型**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**关于 Ollama 的说明**:虽然支持将 Ollama 作为提供商,但除非您在本地运行像 DeepSeek R1 或 Qwen3-235B 这样的高性能模型,否则对于此用例通常不太实用。
|
|
||||||
|
|
||||||
## 温度设置 (Temperature)
|
|
||||||
|
|
||||||
您可以通过环境变量选择性地配置温度:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要提示**:对于不支持温度设置的模型(例如以下模型),请勿设置 `TEMPERATURE`:
|
|
||||||
- GPT-5.1 和其他推理模型
|
|
||||||
- 某些专用模型
|
|
||||||
|
|
||||||
未设置时,模型将使用其默认行为。
|
|
||||||
|
|
||||||
## 推荐
|
|
||||||
|
|
||||||
- **最佳体验**:使用支持视觉的模型(GPT-4o, Claude, Gemini)以获得图像转图表功能
|
|
||||||
- **经济实惠**:DeepSeek 提供具有竞争力的价格
|
|
||||||
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
|
|
||||||
- **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# 部署到 Cloudflare Workers
|
|
||||||
|
|
||||||
本项目可以通过 **OpenNext 适配器** 部署为 **Cloudflare Worker**,为您提供:
|
|
||||||
|
|
||||||
- 全球边缘部署
|
|
||||||
- 极低延迟
|
|
||||||
- 免费的 `workers.dev` 域名托管
|
|
||||||
- 通过 R2 实现完整的 Next.js ISR 支持(可选)
|
|
||||||
|
|
||||||
> **Windows 用户重要提示:** OpenNext 和 Wrangler 在 **原生 Windows 环境下并不完全可靠**。建议方案:
|
|
||||||
>
|
|
||||||
> - 使用 **GitHub Codespaces**(完美运行)
|
|
||||||
> - 或者使用 **WSL (Linux)**
|
|
||||||
>
|
|
||||||
> 纯 Windows 构建可能会因为 WASM 文件路径问题而失败。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前置条件
|
|
||||||
|
|
||||||
1. 一个 **Cloudflare 账户**(免费版即可满足基本部署需求)
|
|
||||||
2. **Node.js 18+**
|
|
||||||
3. 安装 **Wrangler CLI**(作为开发依赖安装即可):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 登录 Cloudflare:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 只有在启用 R2 进行 ISR 缓存时才需要绑定支付方式。基本的 Workers 部署是免费的。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第一步 — 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二步 — 配置环境变量
|
|
||||||
|
|
||||||
Cloudflare 在本地测试时使用不同的文件。
|
|
||||||
|
|
||||||
### 1) 创建 `.dev.vars`(用于 Cloudflare 本地调试 + 部署)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .dev.vars
|
|
||||||
```
|
|
||||||
|
|
||||||
填入您的 API 密钥和配置信息。
|
|
||||||
|
|
||||||
### 2) 确保 `.env.local` 也存在(用于常规 Next.js 开发)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
在此处填入相同的值。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第三步 — 选择部署类型
|
|
||||||
|
|
||||||
### 选项 A:不使用 R2 部署(简单,免费)
|
|
||||||
|
|
||||||
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
|
||||||
|
|
||||||
**1. 使用简单的 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
直接跳至 **第四步**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
|
||||||
|
|
||||||
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
|
||||||
|
|
||||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
|
||||||
|
|
||||||
- 进入 **Storage & Databases → R2**
|
|
||||||
- 点击 **Create bucket**
|
|
||||||
- 命名为:`next-inc-cache`
|
|
||||||
|
|
||||||
**2. 配置 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: r2IncrementalCache,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
|
||||||
"bucket_name": "next-inc-cache"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
|
||||||
|
|
||||||
在首次部署之前,您需要一个 workers.dev 子域名。
|
|
||||||
|
|
||||||
**选项 1:通过 Cloudflare 控制台(推荐)**
|
|
||||||
|
|
||||||
访问:https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
|
||||||
|
|
||||||
**选项 2:在部署过程中**
|
|
||||||
|
|
||||||
运行 `npm run deploy` 时,Wrangler 可能会提示:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to register a workers.dev subdomain? (Y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
输入 `Y` 并选择一个子域名。
|
|
||||||
|
|
||||||
> **注意:** 在 CI/CD 或非交互式环境中,该提示不会出现。请先通过控制台进行注册。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第五步 — 部署到 Cloudflare
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本执行的操作:
|
|
||||||
|
|
||||||
- 构建 Next.js 应用
|
|
||||||
- 通过 OpenNext 将其转换为 Cloudflare Worker
|
|
||||||
- 上传静态资源
|
|
||||||
- 发布 Worker
|
|
||||||
|
|
||||||
您的应用将可通过以下地址访问:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<worker-name>.<your-subdomain>.workers.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题与修复
|
|
||||||
|
|
||||||
### `You need to register a workers.dev subdomain`
|
|
||||||
|
|
||||||
**原因:** 您的账户尚未注册 workers.dev 子域名。
|
|
||||||
|
|
||||||
**修复:** 前往 https://dash.cloudflare.com → Workers & Pages → Set up a subdomain。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Please enable R2 through the Cloudflare Dashboard`
|
|
||||||
|
|
||||||
**原因:** wrangler.jsonc 中配置了 R2,但您的账户尚未启用该功能。
|
|
||||||
|
|
||||||
**修复:** 启用 R2(需要支付方式)或使用选项 A(不使用 R2 部署)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
|
||||||
|
|
||||||
**原因:** `wrangler.jsonc` 中缺少 `r2_buckets` 配置。
|
|
||||||
|
|
||||||
**修复:** 添加 `r2_buckets` 部分或切换到选项 A(不使用 R2)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Can't set compatibility date in the future`
|
|
||||||
|
|
||||||
**原因:** wrangler 配置中的 `compatibility_date` 设置为了未来的日期。
|
|
||||||
|
|
||||||
**修复:** 将 `compatibility_date` 修改为今天或更早的日期。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows 错误:`resvg.wasm?module` (ENOENT)
|
|
||||||
|
|
||||||
**原因:** Windows 文件名不能包含 `?`,但某个 wasm 资源文件名中使用了 `?module`。
|
|
||||||
|
|
||||||
**修复:** 在 Linux 环境(WSL、Codespaces 或 CI)上进行构建/部署。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 可选:本地预览
|
|
||||||
|
|
||||||
部署前在本地预览 Worker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
| 功能 | 不使用 R2 | 使用 R2 |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| 成本 | 免费 | 需要绑定支付方式 |
|
|
||||||
| ISR 缓存 | 无 | 有 |
|
|
||||||
| 静态页面 | 支持 | 支持 |
|
|
||||||
| API 路由 | 支持 | 支持 |
|
|
||||||
| 配置复杂度 | 简单 | 中等 |
|
|
||||||
|
|
||||||
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 使用 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
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用环境变量文件:
|
|
||||||
|
|
||||||
```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 提供商配置。查看 [AI 提供商](./ai-providers.md) 了解可用选项。
|
|
||||||
|
|
||||||
> **离线部署:** 如果无法访问 `embed.diagrams.net`,请参阅 [离线部署](./offline-deployment.md) 了解配置选项。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 离线部署
|
|
||||||
|
|
||||||
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
|
|
||||||
|
|
||||||
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
|
|
||||||
|
|
||||||
## Docker Compose 设置
|
|
||||||
|
|
||||||
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
|
|
||||||
2. 创建 `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
depends_on: [drawio]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
|
|
||||||
|
|
||||||
## 配置与重要警告
|
|
||||||
|
|
||||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
|
|
||||||
|
|
||||||
| 场景 | URL 值 |
|
|
||||||
|----------|-----------|
|
|
||||||
| 本地主机 (Localhost) | `http://localhost:8080` |
|
|
||||||
| 远程/服务器 | `http://YOUR_SERVER_IP:8080` |
|
|
||||||
|
|
||||||
**切勿使用** Docker 内部别名(如 `http://drawio:8080`),因为浏览器无法解析它们。
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Frequently Asked Questions (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Cannot Export PDF
|
|
||||||
|
|
||||||
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
|
|
||||||
|
|
||||||
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
|
|
||||||
|
|
||||||
**Solution**: Export as image (PNG) first, then print to PDF
|
|
||||||
|
|
||||||
**Related Issues**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
|
|
||||||
|
|
||||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
|
||||||
|
|
||||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
|
||||||
|
|
||||||
**Solution**: Must pass via `args` at build time:
|
|
||||||
|
|
||||||
```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://your-server-ip:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
|
|
||||||
|
|
||||||
**Related Issues**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Self-hosted Model Only Thinks But Doesn't Draw
|
|
||||||
|
|
||||||
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
|
|
||||||
2. **Tool calling not enabled** - Model service needs tool use configuration
|
|
||||||
|
|
||||||
**Solution**: Enable tool calling, e.g., vLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**Related Issues**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. "No Image Provided" After Uploading Image
|
|
||||||
|
|
||||||
**Problem**: After uploading an image, the system shows "No image provided" error
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
|
|
||||||
- Models with `vision` or `vl` in name support images
|
|
||||||
- Update to latest version (v0.4.9+)
|
|
||||||
|
|
||||||
**Related Issues**: #324, #421, #469
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Admin Panel
|
|
||||||
|
|
||||||
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
|
|
||||||
|
|
||||||
## Enabling the panel
|
|
||||||
|
|
||||||
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
|
||||||
2. Visit `/admin` and sign in.
|
|
||||||
|
|
||||||
## What you can configure
|
|
||||||
|
|
||||||
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
|
||||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
|
||||||
|
|
||||||
## Precedence
|
|
||||||
|
|
||||||
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
|
|
||||||
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
|
|
||||||
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
|
|
||||||
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
# AI Provider Configuration
|
|
||||||
|
|
||||||
This guide explains how to configure different AI model providers for next-ai-draw-io.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env.local`
|
|
||||||
2. Set your API key for your chosen provider
|
|
||||||
3. Set `AI_MODEL` to your desired model
|
|
||||||
4. Run `npm run dev`
|
|
||||||
|
|
||||||
## Supported Providers
|
|
||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
|
||||||
|
|
||||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Vertex AI (Enterprise GCP)
|
|
||||||
|
|
||||||
Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_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
|
|
||||||
```
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/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
|
|
||||||
```
|
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
|
||||||
|
|
||||||
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
|
||||||
|
|
||||||
**Basic Usage (Vercel-hosted Gateway):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
**Custom Gateway URL (for local development or self-hosted Gateway):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_custom_api_key
|
|
||||||
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
Model format uses `provider/model` syntax:
|
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
|
||||||
|
|
||||||
**Configuration notes:**
|
|
||||||
|
|
||||||
- If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used
|
|
||||||
- Custom base URL is useful for:
|
|
||||||
- Local development with a custom Gateway instance
|
|
||||||
- Self-hosted AI Gateway deployments
|
|
||||||
- Enterprise proxy configurations
|
|
||||||
- When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`
|
|
||||||
|
|
||||||
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax supports two API formats:
|
|
||||||
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
|
|
||||||
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# China mainland, Anthropic-compatible (default)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# China mainland, OpenAI-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# International, Anthropic-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# International, OpenAI-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (Zhipu AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (Alibaba Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (Qiniu Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection
|
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
|
||||||
|
|
||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
|
|
||||||
|
|
||||||
### Configuration Methods
|
|
||||||
|
|
||||||
**Option 1: Environment Variable** (recommended for cloud deployments)
|
|
||||||
|
|
||||||
Set `AI_MODELS_CONFIG` as a JSON string:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Config File**
|
|
||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
|
||||||
|
|
||||||
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
|
||||||
|
|
||||||
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
|
||||||
|
|
||||||
### Example Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Reference
|
|
||||||
|
|
||||||
| Field | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `name` | Yes | Display name (supports multiple configs for same provider) |
|
|
||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
|
||||||
| `models` | Yes | List of model IDs |
|
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
|
|
||||||
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
|
|
||||||
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
|
|
||||||
|
|
||||||
## 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,267 +0,0 @@
|
|||||||
# Deploy on Cloudflare Workers
|
|
||||||
|
|
||||||
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
|
|
||||||
|
|
||||||
- Global edge deployment
|
|
||||||
- Very low latency
|
|
||||||
- Free `workers.dev` hosting
|
|
||||||
- Full Next.js ISR support via R2 (optional)
|
|
||||||
|
|
||||||
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
|
|
||||||
>
|
|
||||||
> - Use **GitHub Codespaces** (works perfectly)
|
|
||||||
> - OR use **WSL (Linux)**
|
|
||||||
>
|
|
||||||
> Pure Windows builds may fail due to WASM file path issues.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. A **Cloudflare account** (free tier works for basic deployment)
|
|
||||||
2. **Node.js 18+**
|
|
||||||
3. **Wrangler CLI** installed (dev dependency is fine):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Cloudflare login:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Configure environment variables
|
|
||||||
|
|
||||||
Cloudflare uses a different file for local testing.
|
|
||||||
|
|
||||||
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .dev.vars
|
|
||||||
```
|
|
||||||
|
|
||||||
Fill in your API keys and configuration.
|
|
||||||
|
|
||||||
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Fill in the same values there.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Choose your deployment type
|
|
||||||
|
|
||||||
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
|
||||||
|
|
||||||
If you don't need ISR caching, you can deploy without R2:
|
|
||||||
|
|
||||||
**1. Use simple `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Skip to **Step 4**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Option B: Deploy WITH R2 (Full ISR Support)
|
|
||||||
|
|
||||||
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
|
||||||
|
|
||||||
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
|
||||||
|
|
||||||
- Go to **Storage & Databases → R2**
|
|
||||||
- Click **Create bucket**
|
|
||||||
- Name it: `next-inc-cache`
|
|
||||||
|
|
||||||
**2. Configure `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: r2IncrementalCache,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Configure `wrangler.jsonc` (with R2):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
|
||||||
"bucket_name": "next-inc-cache"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4 — Register a workers.dev subdomain (first-time only)
|
|
||||||
|
|
||||||
Before your first deployment, you need a workers.dev subdomain.
|
|
||||||
|
|
||||||
**Option 1: Via Cloudflare Dashboard (Recommended)**
|
|
||||||
|
|
||||||
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
|
||||||
|
|
||||||
**Option 2: During deploy**
|
|
||||||
|
|
||||||
When you run `npm run deploy`, Wrangler may prompt:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to register a workers.dev subdomain? (Y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
Type `Y` and choose a subdomain name.
|
|
||||||
|
|
||||||
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — Deploy to Cloudflare
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
What the script does:
|
|
||||||
|
|
||||||
- Builds the Next.js app
|
|
||||||
- Converts it to a Cloudflare Worker via OpenNext
|
|
||||||
- Uploads static assets
|
|
||||||
- Publishes the Worker
|
|
||||||
|
|
||||||
Your app will be available at:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<worker-name>.<your-subdomain>.workers.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common issues & fixes
|
|
||||||
|
|
||||||
### `You need to register a workers.dev subdomain`
|
|
||||||
|
|
||||||
**Cause:** No workers.dev subdomain registered for your account.
|
|
||||||
|
|
||||||
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Please enable R2 through the Cloudflare Dashboard`
|
|
||||||
|
|
||||||
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
|
|
||||||
|
|
||||||
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
|
||||||
|
|
||||||
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
|
|
||||||
|
|
||||||
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Can't set compatibility date in the future`
|
|
||||||
|
|
||||||
**Cause:** `compatibility_date` in wrangler config is set to a future date.
|
|
||||||
|
|
||||||
**Fix:** Change `compatibility_date` to today or an earlier date.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows error: `resvg.wasm?module` (ENOENT)
|
|
||||||
|
|
||||||
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
|
|
||||||
|
|
||||||
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional: Preview locally
|
|
||||||
|
|
||||||
Preview the Worker locally before deploying:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Feature | Without R2 | With R2 |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| Cost | Free | Requires payment method |
|
|
||||||
| ISR Caching | No | Yes |
|
|
||||||
| Static Pages | Yes | Yes |
|
|
||||||
| API Routes | Yes | Yes |
|
|
||||||
| Setup Complexity | Simple | Moderate |
|
|
||||||
|
|
||||||
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Run with Docker
|
|
||||||
|
|
||||||
If you just want to run it locally, the best way is to use Docker.
|
|
||||||
|
|
||||||
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e AI_PROVIDER=openai \
|
|
||||||
-e AI_MODEL=gpt-4o \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using server-side model configuration
|
|
||||||
|
|
||||||
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
|
|
||||||
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
|
|
||||||
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 [AI Providers](./ai-providers.md) for available options.
|
|
||||||
|
|
||||||
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./offline-deployment.md) for configuration options.
|
|
||||||
@@ -1,78 +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アドレスが見つかりません」と表示される
|
|
||||||
|
|
||||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
|
||||||
|
|
||||||
**解決策**: ビルド時に `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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 管理パネル
|
|
||||||
|
|
||||||
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
|
|
||||||
|
|
||||||
## パネルの有効化
|
|
||||||
|
|
||||||
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
|
||||||
2. `/admin` にアクセスしてサインインします。
|
|
||||||
|
|
||||||
## 設定できる項目
|
|
||||||
|
|
||||||
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
|
||||||
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
|
||||||
|
|
||||||
## 優先順位
|
|
||||||
|
|
||||||
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
|
|
||||||
|
|
||||||
## 注意事項
|
|
||||||
|
|
||||||
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
|
|
||||||
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
|
|
||||||
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
|
|
||||||
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
# AIプロバイダーの設定
|
|
||||||
|
|
||||||
このガイドでは、next-ai-draw-io でさまざまな AI モデルプロバイダーを設定する方法について説明します。
|
|
||||||
|
|
||||||
## クイックスタート
|
|
||||||
|
|
||||||
1. `.env.example` を `.env.local` にコピーします
|
|
||||||
2. 選択したプロバイダーの API キーを設定します
|
|
||||||
3. `AI_MODEL` を希望のモデルに設定します
|
|
||||||
4. `npm run dev` を実行します
|
|
||||||
|
|
||||||
## 対応プロバイダー
|
|
||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
|
||||||
|
|
||||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント(OpenAI 互換サービス用):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### SiliconFlow (OpenAI 互換)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # 例; 任意の SiliconFlow モデル ID を使用
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント(デフォルトは推奨ドメイン):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # または https://api.siliconflow.cn/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_RESOURCE_NAME=your-resource-name # 必須: Azure リソース名
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
またはリソース名の代わりにカスタムエンドポイントを使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME の代替
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
任意の推論設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_REASONING_EFFORT=low # 任意: low, medium, high
|
|
||||||
AZURE_REASONING_SUMMARY=detailed # 任意: 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
|
|
||||||
```
|
|
||||||
|
|
||||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (ローカル)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタム URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
|
||||||
|
|
||||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
|
||||||
|
|
||||||
**基本的な使用法 (Vercel ホストの Gateway):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
**カスタム Gateway URL (ローカル開発またはセルフホスト Gateway 用):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_custom_api_key
|
|
||||||
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
モデル形式は `provider/model` 構文を使用します:
|
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
|
||||||
|
|
||||||
**設定に関する注意点:**
|
|
||||||
|
|
||||||
- `AI_GATEWAY_BASE_URL` が設定されていない場合、デフォルトの Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) が使用されます
|
|
||||||
- カスタムベース URL は以下の場合に便利です:
|
|
||||||
- カスタム Gateway インスタンスを使用したローカル開発
|
|
||||||
- セルフホスト AI Gateway デプロイメント
|
|
||||||
- エンタープライズプロキシ設定
|
|
||||||
- カスタムベース URL を使用する場合、`AI_GATEWAY_API_KEY` も指定する必要があります
|
|
||||||
|
|
||||||
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax は 2 つの API 形式をサポートしています:
|
|
||||||
- **Anthropic 互換**(`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
|
|
||||||
- **OpenAI 互換**(`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
オプション設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 中国大陸版、Anthropic 互換(デフォルト)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# 中国大陸版、OpenAI 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# 国際版、Anthropic 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# 国際版、OpenAI 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (Zhipu AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (Alibaba Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (Qiniu Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自動検出
|
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
|
||||||
|
|
||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
|
||||||
```
|
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
|
|
||||||
|
|
||||||
### 設定方法
|
|
||||||
|
|
||||||
**方法1:環境変数**(クラウドデプロイ推奨)
|
|
||||||
|
|
||||||
`AI_MODELS_CONFIG` をJSON文字列として設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法2:設定ファイル**
|
|
||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
|
||||||
|
|
||||||
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
|
|
||||||
|
|
||||||
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
|
|
||||||
|
|
||||||
### 設定例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### フィールド説明
|
|
||||||
|
|
||||||
| フィールド | 必須 | 説明 |
|
|
||||||
|------------|------|------|
|
|
||||||
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
|
|
||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
|
||||||
| `models` | はい | モデルIDのリスト |
|
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
|
||||||
|
|
||||||
### 備考
|
|
||||||
|
|
||||||
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名(例:`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
|
|
||||||
- `name` フィールドにより同一プロバイダーの複数設定が可能です(例:「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
|
|
||||||
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
|
|
||||||
|
|
||||||
## モデル性能要件
|
|
||||||
|
|
||||||
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
|
||||||
|
|
||||||
**推奨モデル**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**Ollama に関する注意**: Ollama はプロバイダーとしてサポートされていますが、DeepSeek R1 や Qwen3-235B のような高性能モデルをローカルで実行していない限り、このユースケースでは一般的に実用的ではありません。
|
|
||||||
|
|
||||||
## Temperature(温度)設定
|
|
||||||
|
|
||||||
環境変数で Temperature を任意に設定できます:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨)
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要**: 以下の Temperature 設定をサポートしていないモデルでは、`TEMPERATURE` を未設定のままにしてください:
|
|
||||||
- GPT-5.1 およびその他の推論モデル
|
|
||||||
- 一部の特殊なモデル
|
|
||||||
|
|
||||||
未設定の場合、モデルはデフォルトの挙動を使用します。
|
|
||||||
|
|
||||||
## 推奨事項
|
|
||||||
|
|
||||||
- **最高の体験**: 画像からダイアグラムを生成する機能には、ビジョン(画像認識)をサポートするモデル(GPT-4o, Claude, Gemini)を使用してください
|
|
||||||
- **低コスト**: DeepSeek は競争力のある価格を提供しています
|
|
||||||
- **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です)
|
|
||||||
- **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# Cloudflare Workers へのデプロイ
|
|
||||||
|
|
||||||
このプロジェクトは **OpenNext アダプター** を使用して **Cloudflare Worker** としてデプロイすることができ、以下のメリットがあります:
|
|
||||||
|
|
||||||
- グローバルエッジへのデプロイ
|
|
||||||
- 超低レイテンシー
|
|
||||||
- 無料の `workers.dev` ホスティング
|
|
||||||
- R2 を介した完全な Next.js ISR サポート(オプション)
|
|
||||||
|
|
||||||
> **Windows ユーザー向けの重要な注意:** OpenNext と Wrangler は、**ネイティブ Windows 環境では完全には信頼できません**。以下の方法を推奨します:
|
|
||||||
>
|
|
||||||
> - **GitHub Codespaces** を使用する(完全に動作します)
|
|
||||||
> - または **WSL (Linux)** を使用する
|
|
||||||
>
|
|
||||||
> 純粋な Windows 環境でのビルドは、WASM ファイルパスの問題により失敗する可能性があります。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前提条件
|
|
||||||
|
|
||||||
1. **Cloudflare アカウント**(基本的なデプロイには無料プランで十分です)
|
|
||||||
2. **Node.js 18以上**
|
|
||||||
3. **Wrangler CLI** のインストール(開発依存関係で問題ありません):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Cloudflare へのログイン:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 支払い方法の登録が必要なのは、ISR キャッシュのために R2 を有効にする場合のみです。基本的な Workers へのデプロイは無料です。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 1 — 依存関係のインストール
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 2 — 環境変数の設定
|
|
||||||
|
|
||||||
Cloudflare はローカルテスト用に別のファイルを使用します。
|
|
||||||
|
|
||||||
### 1) `.dev.vars` の作成(Cloudflare ローカルおよびデプロイ用)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .dev.vars
|
|
||||||
```
|
|
||||||
|
|
||||||
API キーと設定を入力してください。
|
|
||||||
|
|
||||||
### 2) `.env.local` も存在することを確認(通常の Next.js 開発用)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
同じ値を入力してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 3 — デプロイタイプの選択
|
|
||||||
|
|
||||||
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
|
||||||
|
|
||||||
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
|
||||||
|
|
||||||
**1. シンプルな `open-next.config.ts` を使用:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 4** へ進んでください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
|
||||||
|
|
||||||
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
|
||||||
|
|
||||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
|
||||||
|
|
||||||
- **Storage & Databases → R2** へ移動
|
|
||||||
- **Create bucket** をクリック
|
|
||||||
- 名前を入力: `next-inc-cache`
|
|
||||||
|
|
||||||
**2. `open-next.config.ts` の設定:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: r2IncrementalCache,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. `wrangler.jsonc` の設定(R2 あり):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
|
||||||
"bucket_name": "next-inc-cache"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
|
||||||
|
|
||||||
初回デプロイの前に、workers.dev サブドメインが必要です。
|
|
||||||
|
|
||||||
**オプション 1: Cloudflare ダッシュボード経由(推奨)**
|
|
||||||
|
|
||||||
アクセス先: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
|
||||||
|
|
||||||
**オプション 2: デプロイ時**
|
|
||||||
|
|
||||||
`npm run deploy` を実行した際、Wrangler が以下のように尋ねてくる場合があります:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to register a workers.dev subdomain? (Y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Y` を入力し、サブドメイン名を選択してください。
|
|
||||||
|
|
||||||
> **注意:** CI/CD や非対話型環境では、このプロンプトは表示されません。事前にダッシュボードで登録してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 5 — Cloudflare へのデプロイ
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
スクリプトの処理内容:
|
|
||||||
|
|
||||||
- Next.js アプリのビルド
|
|
||||||
- OpenNext を介した Cloudflare Worker への変換
|
|
||||||
- 静的アセットのアップロード
|
|
||||||
- Worker の公開
|
|
||||||
|
|
||||||
アプリは以下の URL で利用可能になります:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<worker-name>.<your-subdomain>.workers.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## よくある問題と解決策
|
|
||||||
|
|
||||||
### `You need to register a workers.dev subdomain`
|
|
||||||
|
|
||||||
**原因:** アカウントに workers.dev サブドメインが登録されていません。
|
|
||||||
|
|
||||||
**解決策:** https://dash.cloudflare.com → Workers & Pages → Set up a subdomain から登録してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Please enable R2 through the Cloudflare Dashboard`
|
|
||||||
|
|
||||||
**原因:** `wrangler.jsonc` で R2 が設定されていますが、アカウントで R2 が有効になっていません。
|
|
||||||
|
|
||||||
**解決策:** R2 を有効にする(支払い方法が必要)か、オプション A(R2 なしでデプロイ)を使用してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
|
||||||
|
|
||||||
**原因:** `wrangler.jsonc` に `r2_buckets` がありません。
|
|
||||||
|
|
||||||
**解決策:** `r2_buckets` セクションを追加するか、オプション A(R2 なし)に切り替えてください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Can't set compatibility date in the future`
|
|
||||||
|
|
||||||
**原因:** wrangler 設定の `compatibility_date` が未来の日付に設定されています。
|
|
||||||
|
|
||||||
**解決策:** `compatibility_date` を今日またはそれ以前の日付に変更してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows エラー: `resvg.wasm?module` (ENOENT)
|
|
||||||
|
|
||||||
**原因:** Windows のファイル名には `?` を含めることができませんが、wasm アセットのファイル名に `?module` が使用されているためです。
|
|
||||||
|
|
||||||
**解決策:** Linux 環境(WSL、Codespaces、または CI)でビルド/デプロイしてください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## オプション: ローカルでのプレビュー
|
|
||||||
|
|
||||||
デプロイ前に Worker をローカルでプレビューできます:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## まとめ
|
|
||||||
|
|
||||||
| 機能 | R2 なし | R2 あり |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| コスト | 無料 | 支払い方法が必要 |
|
|
||||||
| ISR キャッシュ | なし | あり |
|
|
||||||
| 静的ページ | あり | あり |
|
|
||||||
| API ルート | あり | あり |
|
|
||||||
| 設定の複雑さ | シンプル | 普通 |
|
|
||||||
|
|
||||||
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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プロバイダー設定に置き換えてください。利用可能なオプションについては、[AIプロバイダー](./ai-providers.md)を参照してください。
|
|
||||||
|
|
||||||
> **オフラインデプロイ:** `embed.diagrams.net`がブロックされている場合は、構成オプションについて[オフラインデプロイ](./offline-deployment.md)を参照してください。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# オフラインデプロイ
|
|
||||||
|
|
||||||
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
|
|
||||||
|
|
||||||
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
|
|
||||||
|
|
||||||
## Docker Compose のセットアップ
|
|
||||||
|
|
||||||
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
|
|
||||||
2. `docker-compose.yml` を作成します。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
depends_on: [drawio]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
|
|
||||||
|
|
||||||
## 設定と重要な警告
|
|
||||||
|
|
||||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
|
|
||||||
|
|
||||||
| シナリオ | URL の値 |
|
|
||||||
|----------|-----------|
|
|
||||||
| ローカルホスト | `http://localhost:8080` |
|
|
||||||
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
|
|
||||||
|
|
||||||
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
|
|
||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
| Scenario | URL Value |
|
| Scenario | URL Value |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| Localhost | `http://localhost:8080` |
|
| Localhost | `http://localhost:8080` |
|
||||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
|
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
|
||||||
|
|
||||||
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,367 +0,0 @@
|
|||||||
# material_design
|
|
||||||
|
|
||||||
**Type:** SVG images (Google Material Icons CDN)
|
|
||||||
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `{icon_name}` with any icon name from the list below.
|
|
||||||
|
|
||||||
## action (115)
|
|
||||||
|
|
||||||
- `account_balance`
|
|
||||||
- `account_balance_wallet`
|
|
||||||
- `account_box`
|
|
||||||
- `account_circle`
|
|
||||||
- `add_shopping_cart`
|
|
||||||
- `admin_panel_settings`
|
|
||||||
- `analytics`
|
|
||||||
- `arrow_right_alt`
|
|
||||||
- `article`
|
|
||||||
- `assessment`
|
|
||||||
- `assignment`
|
|
||||||
- `assignment_ind`
|
|
||||||
- `assignment_turned_in`
|
|
||||||
- `autorenew`
|
|
||||||
- `bookmark`
|
|
||||||
- `bookmark_border`
|
|
||||||
- `build`
|
|
||||||
- `calendar_month`
|
|
||||||
- `calendar_today`
|
|
||||||
- `card_giftcard`
|
|
||||||
- `check_circle`
|
|
||||||
- `check_circle_outline`
|
|
||||||
- `code`
|
|
||||||
- `contact_support`
|
|
||||||
- `credit_card`
|
|
||||||
- `dashboard`
|
|
||||||
- `date_range`
|
|
||||||
- `delete`
|
|
||||||
- `delete_forever`
|
|
||||||
- `delete_outline`
|
|
||||||
- `description`
|
|
||||||
- `dns`
|
|
||||||
- `done`
|
|
||||||
- `done_all`
|
|
||||||
- `done_outline`
|
|
||||||
- `drag_indicator`
|
|
||||||
- `event`
|
|
||||||
- `exit_to_app`
|
|
||||||
- `explore`
|
|
||||||
- `face`
|
|
||||||
- `fact_check`
|
|
||||||
- `favorite`
|
|
||||||
- `favorite_border`
|
|
||||||
- `feedback`
|
|
||||||
- `filter_alt`
|
|
||||||
- `fingerprint`
|
|
||||||
- `flight_takeoff`
|
|
||||||
- `grade`
|
|
||||||
- `help`
|
|
||||||
- `help_outline`
|
|
||||||
- `highlight_off`
|
|
||||||
- `history`
|
|
||||||
- `home`
|
|
||||||
- `info`
|
|
||||||
- `label`
|
|
||||||
- `language`
|
|
||||||
- `launch`
|
|
||||||
- `leaderboard`
|
|
||||||
- `lightbulb`
|
|
||||||
- `list`
|
|
||||||
- `lock`
|
|
||||||
- `lock_open`
|
|
||||||
- `login`
|
|
||||||
- `logout`
|
|
||||||
- `manage_accounts`
|
|
||||||
- `note_add`
|
|
||||||
- `open_in_full`
|
|
||||||
- `open_in_new`
|
|
||||||
- `paid`
|
|
||||||
- `payment`
|
|
||||||
- `pending`
|
|
||||||
- `pending_actions`
|
|
||||||
- `perm_identity`
|
|
||||||
- `pets`
|
|
||||||
- `power_settings_new`
|
|
||||||
- `preview`
|
|
||||||
- `print`
|
|
||||||
- `published_with_changes`
|
|
||||||
- `question_answer`
|
|
||||||
- `receipt`
|
|
||||||
- `reorder`
|
|
||||||
- `report_problem`
|
|
||||||
- `room`
|
|
||||||
- `savings`
|
|
||||||
- `schedule`
|
|
||||||
- `search`
|
|
||||||
- `settings`
|
|
||||||
- `shopping_bag`
|
|
||||||
- `shopping_basket`
|
|
||||||
- `shopping_cart`
|
|
||||||
- `star_rate`
|
|
||||||
- `stars`
|
|
||||||
- `store`
|
|
||||||
- `supervisor_account`
|
|
||||||
- `swap_horiz`
|
|
||||||
- `sync_alt`
|
|
||||||
- `task_alt`
|
|
||||||
- `thumb_up`
|
|
||||||
- `thumb_up_off_alt`
|
|
||||||
- `timeline`
|
|
||||||
- `tips_and_updates`
|
|
||||||
- `today`
|
|
||||||
- `touch_app`
|
|
||||||
- `trending_up`
|
|
||||||
- `update`
|
|
||||||
- `verified`
|
|
||||||
- `verified_user`
|
|
||||||
- `view_in_ar`
|
|
||||||
- `view_list`
|
|
||||||
- `visibility`
|
|
||||||
- `visibility_off`
|
|
||||||
- `watch_later`
|
|
||||||
- `work`
|
|
||||||
- `work_outline`
|
|
||||||
- `zoom_in`
|
|
||||||
|
|
||||||
## alert (4)
|
|
||||||
|
|
||||||
- `error`
|
|
||||||
- `error_outline`
|
|
||||||
- `warning`
|
|
||||||
- `warning_amber`
|
|
||||||
|
|
||||||
## av (12)
|
|
||||||
|
|
||||||
- `library_books`
|
|
||||||
- `mic`
|
|
||||||
- `pause`
|
|
||||||
- `play_arrow`
|
|
||||||
- `play_circle`
|
|
||||||
- `play_circle_filled`
|
|
||||||
- `play_circle_outline`
|
|
||||||
- `replay`
|
|
||||||
- `skip_next`
|
|
||||||
- `videocam`
|
|
||||||
- `volume_off`
|
|
||||||
- `volume_up`
|
|
||||||
|
|
||||||
## communication (13)
|
|
||||||
|
|
||||||
- `alternate_email`
|
|
||||||
- `business`
|
|
||||||
- `call`
|
|
||||||
- `chat`
|
|
||||||
- `chat_bubble_outline`
|
|
||||||
- `email`
|
|
||||||
- `forum`
|
|
||||||
- `list_alt`
|
|
||||||
- `location_on`
|
|
||||||
- `mail_outline`
|
|
||||||
- `phone`
|
|
||||||
- `qr_code_scanner`
|
|
||||||
- `vpn_key`
|
|
||||||
|
|
||||||
## content (27)
|
|
||||||
|
|
||||||
- `add`
|
|
||||||
- `add_box`
|
|
||||||
- `add_circle`
|
|
||||||
- `add_circle_outline`
|
|
||||||
- `block`
|
|
||||||
- `bolt`
|
|
||||||
- `calculate`
|
|
||||||
- `clear`
|
|
||||||
- `content_copy`
|
|
||||||
- `create`
|
|
||||||
- `filter_list`
|
|
||||||
- `flag`
|
|
||||||
- `how_to_reg`
|
|
||||||
- `insights`
|
|
||||||
- `inventory`
|
|
||||||
- `inventory_2`
|
|
||||||
- `link`
|
|
||||||
- `mail`
|
|
||||||
- `push_pin`
|
|
||||||
- `remove`
|
|
||||||
- `remove_circle`
|
|
||||||
- `remove_circle_outline`
|
|
||||||
- `reply`
|
|
||||||
- `save`
|
|
||||||
- `send`
|
|
||||||
- `sort`
|
|
||||||
- `undo`
|
|
||||||
|
|
||||||
## device (9)
|
|
||||||
|
|
||||||
- `dark_mode`
|
|
||||||
- `devices`
|
|
||||||
- `light_mode`
|
|
||||||
- `password`
|
|
||||||
- `restart_alt`
|
|
||||||
- `sell`
|
|
||||||
- `signal_cellular_alt`
|
|
||||||
- `summarize`
|
|
||||||
- `task`
|
|
||||||
|
|
||||||
## editor (9)
|
|
||||||
|
|
||||||
- `attach_file`
|
|
||||||
- `attach_money`
|
|
||||||
- `bar_chart`
|
|
||||||
- `checklist`
|
|
||||||
- `edit_note`
|
|
||||||
- `format_list_bulleted`
|
|
||||||
- `mode_edit`
|
|
||||||
- `monetization_on`
|
|
||||||
- `post_add`
|
|
||||||
|
|
||||||
## file (8)
|
|
||||||
|
|
||||||
- `cloud_upload`
|
|
||||||
- `download`
|
|
||||||
- `file_download`
|
|
||||||
- `file_upload`
|
|
||||||
- `folder`
|
|
||||||
- `folder_open`
|
|
||||||
- `grid_view`
|
|
||||||
- `upload_file`
|
|
||||||
|
|
||||||
## hardware (6)
|
|
||||||
|
|
||||||
- `computer`
|
|
||||||
- `keyboard_arrow_down`
|
|
||||||
- `keyboard_arrow_right`
|
|
||||||
- `phone_iphone`
|
|
||||||
- `security`
|
|
||||||
- `smartphone`
|
|
||||||
|
|
||||||
## image (16)
|
|
||||||
|
|
||||||
- `add_a_photo`
|
|
||||||
- `auto_awesome`
|
|
||||||
- `auto_stories`
|
|
||||||
- `circle`
|
|
||||||
- `collections`
|
|
||||||
- `edit`
|
|
||||||
- `image`
|
|
||||||
- `navigate_before`
|
|
||||||
- `navigate_next`
|
|
||||||
- `palette`
|
|
||||||
- `photo_camera`
|
|
||||||
- `picture_as_pdf`
|
|
||||||
- `receipt_long`
|
|
||||||
- `remove_red_eye`
|
|
||||||
- `timer`
|
|
||||||
- `tune`
|
|
||||||
|
|
||||||
## maps (11)
|
|
||||||
|
|
||||||
- `badge`
|
|
||||||
- `category`
|
|
||||||
- `directions_car`
|
|
||||||
- `local_fire_department`
|
|
||||||
- `local_offer`
|
|
||||||
- `local_shipping`
|
|
||||||
- `map`
|
|
||||||
- `menu_book`
|
|
||||||
- `place`
|
|
||||||
- `restaurant`
|
|
||||||
- `volunteer_activism`
|
|
||||||
|
|
||||||
## navigation (29)
|
|
||||||
|
|
||||||
- `apps`
|
|
||||||
- `arrow_back`
|
|
||||||
- `arrow_back_ios`
|
|
||||||
- `arrow_back_ios_new`
|
|
||||||
- `arrow_downward`
|
|
||||||
- `arrow_drop_down`
|
|
||||||
- `arrow_drop_up`
|
|
||||||
- `arrow_forward`
|
|
||||||
- `arrow_forward_ios`
|
|
||||||
- `arrow_right`
|
|
||||||
- `arrow_upward`
|
|
||||||
- `campaign`
|
|
||||||
- `cancel`
|
|
||||||
- `check`
|
|
||||||
- `chevron_left`
|
|
||||||
- `chevron_right`
|
|
||||||
- `close`
|
|
||||||
- `double_arrow`
|
|
||||||
- `east`
|
|
||||||
- `expand_less`
|
|
||||||
- `expand_more`
|
|
||||||
- `fullscreen`
|
|
||||||
- `menu`
|
|
||||||
- `menu_open`
|
|
||||||
- `more_horiz`
|
|
||||||
- `more_vert`
|
|
||||||
- `payments`
|
|
||||||
- `refresh`
|
|
||||||
- `unfold_more`
|
|
||||||
|
|
||||||
## notification (6)
|
|
||||||
|
|
||||||
- `account_tree`
|
|
||||||
- `event_available`
|
|
||||||
- `priority_high`
|
|
||||||
- `support_agent`
|
|
||||||
- `sync`
|
|
||||||
- `wifi`
|
|
||||||
|
|
||||||
## places (2)
|
|
||||||
|
|
||||||
- `apartment`
|
|
||||||
- `storefront`
|
|
||||||
|
|
||||||
## search (2)
|
|
||||||
|
|
||||||
- `feed`
|
|
||||||
- `manage_search`
|
|
||||||
|
|
||||||
## social (23)
|
|
||||||
|
|
||||||
- `construction`
|
|
||||||
- `emoji_emotions`
|
|
||||||
- `emoji_events`
|
|
||||||
- `engineering`
|
|
||||||
- `group`
|
|
||||||
- `group_add`
|
|
||||||
- `groups`
|
|
||||||
- `health_and_safety`
|
|
||||||
- `notifications`
|
|
||||||
- `notifications_active`
|
|
||||||
- `notifications_none`
|
|
||||||
- `people`
|
|
||||||
- `people_alt`
|
|
||||||
- `person`
|
|
||||||
- `person_add`
|
|
||||||
- `person_outline`
|
|
||||||
- `psychology`
|
|
||||||
- `public`
|
|
||||||
- `school`
|
|
||||||
- `share`
|
|
||||||
- `thumb_up_alt`
|
|
||||||
- `travel_explore`
|
|
||||||
- `water_drop`
|
|
||||||
|
|
||||||
## toggle (8)
|
|
||||||
|
|
||||||
- `check_box`
|
|
||||||
- `check_box_outline_blank`
|
|
||||||
- `radio_button_checked`
|
|
||||||
- `radio_button_unchecked`
|
|
||||||
- `star`
|
|
||||||
- `star_border`
|
|
||||||
- `star_outline`
|
|
||||||
- `toggle_on`
|
|
||||||
|
|
||||||
Total: 300 icons (top by popularity from 2100+ available)
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user