mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 03:29:50 +08:00
Compare commits
284
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab33dae69d | ||
|
|
0b63e28e5a | ||
|
|
a45e5b6796 | ||
|
|
027cd88c90 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 | ||
|
|
0f9699843f | ||
|
|
988034cc3e | ||
|
|
8bc9871829 | ||
|
|
449e4c4e26 | ||
|
|
54ff8d982c | ||
|
|
410993a3bf | ||
|
|
77e7766f9a | ||
|
|
a9ffd6a1de | ||
|
|
277ad83552 | ||
|
|
7b6eb39fa5 | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
524b77a948 | ||
|
|
9bf0c7f23f | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
43cc4cb657 | ||
|
|
fd84aa70db | ||
|
|
8b0beb68c0 | ||
|
|
7a6a933cf6 | ||
|
|
05bda50b2a | ||
|
|
e7453e86a6 | ||
|
|
be4bc916fd | ||
|
|
c4ba5d4ea8 | ||
|
|
ff07975574 | ||
|
|
ffac74b0ff | ||
|
|
69bd13bc93 | ||
|
|
dd9d79d2d6 | ||
|
|
f3c3614b53 | ||
|
|
6586c53093 | ||
|
|
c083782802 | ||
|
|
a5d1554c3f | ||
|
|
e171fbcdd8 | ||
|
|
89d3968733 | ||
|
|
ac3570c1b0 | ||
|
|
41dc0b2b42 | ||
|
|
3041dafe2f | ||
|
|
7fbc857d3a | ||
|
|
d752524851 | ||
|
|
ecce0a96c7 | ||
|
|
cd33e131ef | ||
|
|
4624ad40a1 | ||
|
|
fa1548d3eb | ||
|
|
eb834a341f | ||
|
|
b84b156c8c | ||
|
|
c6eab07622 | ||
|
|
c382d3c0f4 | ||
|
|
e065d4b727 | ||
|
|
b2b9614404 | ||
|
|
1b96325fea | ||
|
|
19dd4e287f | ||
|
|
699767f392 | ||
|
|
feeb9ec0c5 | ||
|
|
f1a3044224 | ||
|
|
71a1d02e28 | ||
|
|
2d17963b2a | ||
|
|
3721b16d18 | ||
|
|
cbde35fb54 | ||
|
|
f098e557fc | ||
|
|
a245dcb150 | ||
|
|
f2f5ac907d | ||
|
|
0a79536002 | ||
|
|
f2c8fea58d | ||
|
|
31fffcc52d | ||
|
|
0a03ac9f45 | ||
|
|
6dad6ab147 | ||
|
|
8781520ebb | ||
|
|
67196225f0 | ||
|
|
09a5774cba | ||
|
|
fdd4be6463 | ||
|
|
f8a0ebd149 | ||
|
|
c9dd54dad7 | ||
|
|
1258f98478 | ||
|
|
f6f693ee8d | ||
|
|
f19dc919e1 | ||
|
|
9398117368 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 | ||
|
|
0baa424bc4 | ||
|
|
afddba364b | ||
|
|
b386dc45e6 | ||
|
|
7b5a3075cf | ||
|
|
89a0e6d475 | ||
|
|
56df2678bf | ||
|
|
c9e0841583 | ||
|
|
552a2b2ab4 | ||
|
|
78ce5611d3 | ||
|
|
629ba16e7c | ||
|
|
91ca2d4f21 | ||
|
|
9655811425 | ||
|
|
31d0e6d3dc | ||
|
|
92e908aed8 | ||
|
|
dcb6505b49 | ||
|
|
4ace31d412 | ||
|
|
9caf2f793e | ||
|
|
21567744ad | ||
|
|
44699940ce | ||
|
|
5007c7bbe4 | ||
|
|
1ad6575e04 | ||
|
|
85be3a2561 | ||
|
|
b23b9179a0 | ||
|
|
b128c57e94 | ||
|
|
4691a71190 | ||
|
|
04290a53d0 | ||
|
|
3731301162 | ||
|
|
3b50c08258 | ||
|
|
e5f647171c | ||
|
|
476ef3c7d1 | ||
|
|
6bd26c8bbd | ||
|
|
d2e51f159f | ||
|
|
9677737745 | ||
|
|
d5774b336c | ||
|
|
6b70fdbeda | ||
|
|
af913f7223 | ||
|
|
72d438e53a | ||
|
|
0d79487b6c | ||
|
|
75e578b5fc | ||
|
|
0009900b1b | ||
|
|
6a20f03805 | ||
|
|
8f538193dd | ||
|
|
cf9638b231 | ||
|
|
fbce1baf16 | ||
|
|
c3d3afc202 | ||
|
|
651238529a | ||
|
|
35ab222343 | ||
|
|
b7eaf46555 | ||
|
|
5eb797b191 | ||
|
|
bc0f96d3c9 | ||
|
|
f8f197db7b | ||
|
|
518b72a0be | ||
|
|
5e503551e6 | ||
|
|
09d478f6b2 | ||
|
|
1860233923 | ||
|
|
7bdb43cd20 | ||
|
|
3d816442f6 | ||
|
|
f59a2f2b01 | ||
|
|
17b765bfd9 | ||
|
|
555a21033b | ||
|
|
ef6517b89a | ||
|
|
945dffc949 | ||
|
|
0b41084c24 | ||
|
|
860eabd593 | ||
|
|
4ec901e713 | ||
|
|
3959e909c4 | ||
|
|
4a0973a373 | ||
|
|
cb09f8c74e | ||
|
|
53dbd5320b | ||
|
|
32d1361ffa | ||
|
|
4cf9661adb | ||
|
|
9430618660 | ||
|
|
d71fe70cbe | ||
|
|
22f4c2e270 | ||
|
|
73f282e568 | ||
|
|
085d656a3c | ||
|
|
d22474b541 | ||
|
|
53a2b8a0be | ||
|
|
083c2a4142 | ||
|
|
c4b1ec8d28 | ||
|
|
6ad4a9b303 | ||
|
|
dcf222114c | ||
|
|
4ece615548 | ||
|
|
54fd48506d | ||
|
|
ffcb241383 | ||
|
|
79491e2143 | ||
|
|
6326f9dec6 | ||
|
|
625d8f2afe | ||
|
|
0026639ee8 | ||
|
|
c7a85d398f | ||
|
|
3ce047f794 | ||
|
|
2c2d35940b | ||
|
|
02366cabfb | ||
|
|
3e0c3bcb36 | ||
|
|
ce2237f92e | ||
|
|
2637da3215 | ||
|
|
24325c178f | ||
|
|
814f448cb0 | ||
|
|
4dc774d03f | ||
|
|
bc22b7c315 | ||
|
|
8c1cc19d94 | ||
|
|
03c3ae6d5b | ||
|
|
ddde0654a6 | ||
|
|
bc5709267c | ||
|
|
6fbc7b340f | ||
|
|
3c8f420c3c | ||
|
|
f240c494ac | ||
|
|
a22d7025a3 | ||
|
|
2159db5586 | ||
|
|
ada06260db | ||
|
|
02527526ba | ||
|
|
77a2f6f6fa | ||
|
|
493ee168b1 | ||
|
|
037f32973a | ||
|
|
7bdc1fe612 | ||
|
|
03ac9a79de | ||
|
|
f97934d6e0 | ||
|
|
73a36cf9de | ||
|
|
69f9df1792 | ||
|
|
aaa2938dac | ||
|
|
24afa0b58a | ||
|
|
1d19127855 | ||
|
|
ca21a5bb27 | ||
|
|
ad80e9c6f5 | ||
|
|
1ab8d260a2 | ||
|
|
2d62496f9f | ||
|
|
c2aa7f49be | ||
|
|
30b30550d9 |
+49
-3
@@ -20,15 +20,61 @@ npm run lint # Check lint errors
|
|||||||
npm run check # Run all checks (CI)
|
npm run check # Run all checks (CI)
|
||||||
```
|
```
|
||||||
|
|
||||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
Git hooks via Husky run automatically:
|
||||||
|
- **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 and ensure `npm run check` passes
|
2. Make changes (pre-commit runs lint + type check automatically)
|
||||||
3. Submit PR against `main` with a clear description
|
3. Run E2E tests with `npm run test:e2e`
|
||||||
|
4. Push (pre-push runs unit tests automatically)
|
||||||
|
5. Submit PR against `main` with a clear description
|
||||||
|
|
||||||
|
CI will run the full test suite on your PR.
|
||||||
|
|
||||||
|
## Using AI Tools
|
||||||
|
|
||||||
|
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
|
||||||
|
|
||||||
|
1. **Review the code** — understand what was generated, don't just commit blindly
|
||||||
|
2. **Write a PR description** — explain what changed and why
|
||||||
|
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
|
||||||
|
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
|
||||||
|
|
||||||
|
## Code Review
|
||||||
|
|
||||||
|
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||||
|
- **Valid suggestions**: Please address them in your code.
|
||||||
|
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"],
|
"extends": ["config:recommended"],
|
||||||
"schedule": ["after 10am on saturday"],
|
"schedule": ["after 10am on the first day of the month"],
|
||||||
"timezone": "Asia/Tokyo",
|
"timezone": "Asia/Tokyo",
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"matchPackagePatterns": ["*"],
|
"matchPackagePatterns": ["*"],
|
||||||
|
"groupName": "major dependencies",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,11 @@
|
|||||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
"groupName": "Core framework packages",
|
"groupName": "Core framework packages",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["@biomejs/biome"],
|
||||||
|
"groupName": "Biome",
|
||||||
|
"automerge": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Run Biome format
|
- name: Run Biome format
|
||||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
# Pin to the version in package.json so CI matches local/pre-commit
|
||||||
|
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||||
|
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -40,5 +40,3 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Security audit
|
|
||||||
run: npm audit --audit-level=high --omit=dev
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ jobs:
|
|||||||
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
|
||||||
@@ -89,4 +91,3 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Mac and Linux: Build and publish directly (no signing needed)
|
||||||
|
build-mac-linux:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -20,13 +21,9 @@ 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@v6
|
||||||
@@ -37,10 +34,80 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Download draw.io static files for offline use
|
||||||
run: npm ci
|
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: Build and publish Electron app
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build and publish
|
||||||
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 }}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Publish MCP Server
|
||||||
|
|
||||||
|
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||||
|
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||||
|
# skips silently if the package.json version is already on npm — so a
|
||||||
|
# release is just "bump the version in a PR and merge".
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/mcp-server/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # OIDC token for npm trusted publishing
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-mcp
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/mcp-server
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
# Trusted publishing requires npm >= 11.5.1
|
||||||
|
- name: Update npm
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- name: Check if version is already published
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
LOCAL=$(node -p "require('./package.json').version")
|
||||||
|
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||||
|
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||||
|
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Version ${LOCAL} not on npm - publishing"
|
||||||
|
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build and check package contents
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm run build && npm run check-package
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm publish
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-unit:
|
||||||
|
name: Lint & Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test -- --run
|
||||||
|
|
||||||
|
# The MCP server package ships its own vitest because its DOM polyfill
|
||||||
|
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||||
|
# for the Next.js app. Install + run its tests separately so CI catches
|
||||||
|
# multi-page mxfile regressions.
|
||||||
|
- name: Install MCP server dependencies
|
||||||
|
run: npm --prefix packages/mcp-server ci
|
||||||
|
|
||||||
|
- name: Run MCP server unit tests
|
||||||
|
run: npm --prefix packages/mcp-server test
|
||||||
|
|
||||||
|
# Tests run from src/, so check the built npm package separately
|
||||||
|
- name: Build MCP server and check package contents
|
||||||
|
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v5
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Install Playwright deps (cached)
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
+17
-1
@@ -14,6 +14,8 @@ packages/*/dist
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -54,6 +56,8 @@ 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
|
||||||
@@ -62,4 +66,16 @@ push-via-ec2.sh
|
|||||||
*.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 +1,2 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
npx tsc --noEmit
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Skip if node_modules not installed (e.g., on EC2 push server)
|
||||||
|
if [ -d "node_modules" ]; then
|
||||||
|
npm run test -- --run
|
||||||
|
fi
|
||||||
+13
@@ -9,6 +9,7 @@ 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 install
|
RUN npm install
|
||||||
|
|
||||||
# Stage 2: Build application
|
# Stage 2: Build application
|
||||||
@@ -30,6 +31,15 @@ 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
|
||||||
|
|
||||||
@@ -51,6 +61,9 @@ 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||||
|
|
||||||
English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
|
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -19,7 +19,18 @@ English | [中文](./docs/README_CN.md) | [日本語](./docs/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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
|
> 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
|
||||||
@@ -31,20 +42,23 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Table of Contents](#table-of-contents)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [MCP Server (Preview)](#mcp-server-preview)
|
- [MCP Server](#mcp-server)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [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 (Recommended)](#run-with-docker-recommended)
|
- [Run with Docker](#run-with-docker)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||||
|
- [Deploy on Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [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
|
||||||
@@ -62,24 +76,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>GCP architecture diagram</strong><br />
|
<strong>RAG Technique Diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS architecture diagram</strong><br />
|
<strong>Authentication using React and AWS</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure architecture diagram</strong><br />
|
<strong>Open Innovation</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch prompt</strong><br />
|
<strong>Cat sketch</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>
|
||||||
@@ -98,9 +112,7 @@ 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 (Preview)
|
## MCP Server
|
||||||
|
|
||||||
> **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).
|
||||||
|
|
||||||
@@ -126,6 +138,13 @@ Then ask Claude to create diagrams:
|
|||||||
|
|
||||||
The diagram appears in your browser in real-time!
|
The diagram appears in your browser in real-time!
|
||||||
|
|
||||||
|
The MCP server includes most of the web app's drawing features:
|
||||||
|
|
||||||
|
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||||
|
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
||||||
|
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
||||||
|
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
||||||
|
|
||||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -144,53 +163,11 @@ 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):
|
||||||
|
|
||||||
| Platform | Download |
|
Supported platforms: Windows, macOS, Linux.
|
||||||
|----------|----------|
|
|
||||||
| macOS | `.dmg` (Intel & Apple Silicon) |
|
|
||||||
| Windows | `.exe` installer (x64 & ARM64) |
|
|
||||||
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
|
||||||
|
|
||||||
**Features:**
|
### Run with Docker
|
||||||
- **Secure API key storage**: Credentials encrypted using OS keychain
|
|
||||||
- **Configuration presets**: Save and switch between AI providers via menu
|
|
||||||
- **Native file dialogs**: Open/save `.drawio` files directly
|
|
||||||
- **Offline capable**: Works without internet after first launch
|
|
||||||
|
|
||||||
**Quick Setup:**
|
[Go to Docker Guide](./docs/en/docker.md)
|
||||||
1. Download and install for your platform
|
|
||||||
2. Open the app → **Menu → Configuration → Manage Presets**
|
|
||||||
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
|
||||||
|
|
||||||
@@ -199,45 +176,35 @@ Replace the environment variables with your preferred AI provider configuration.
|
|||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.local` and configure your chosen provider:
|
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
||||||
|
|
||||||
- Set `AI_PROVIDER` to your chosen provider (doubao,bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
2. Run the development server:
|
||||||
- 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
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Deploy on Vercel (Recommended)
|
### Deploy to EdgeOne Pages
|
||||||
|
|
||||||
|
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
||||||
|
|
||||||
|
Deploy by this button:
|
||||||
|
|
||||||
|
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
|
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
|
||||||
|
|
||||||
|
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||||
|
|
||||||
|
### Deploy on Vercel
|
||||||
|
|
||||||
[](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)
|
||||||
|
|
||||||
@@ -247,31 +214,47 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
|
|
||||||
### Deploy on Cloudflare Workers
|
### Deploy on Cloudflare Workers
|
||||||
|
|
||||||
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
|
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
- [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/ai-providers.md)** - See setup instructions for each provider.
|
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||||
|
|
||||||
|
### Server-Side Multi-Model Configuration
|
||||||
|
|
||||||
|
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
|
||||||
|
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
||||||
|
|
||||||
|
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
||||||
|
|
||||||
**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 `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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -284,28 +267,12 @@ 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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
**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!
|
||||||
|
|
||||||
@@ -313,6 +280,10 @@ For support or inquiries, please open an issue on the GitHub repository or conta
|
|||||||
|
|
||||||
- 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)
|
||||||
|
|||||||
+91
-127
@@ -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,18 +10,7 @@ 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 */}
|
||||||
@@ -72,28 +61,6 @@ 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-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -111,7 +78,7 @@ export default function AboutCN() {
|
|||||||
<p>
|
<p>
|
||||||
好消息!感谢{" "}
|
好消息!感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -120,7 +87,7 @@ export default function AboutCN() {
|
|||||||
</a>
|
</a>
|
||||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -130,40 +97,21 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
{/* Invite Poster */}
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
<div className="text-center mb-5">
|
||||||
当前使用限制:
|
<a
|
||||||
</p>
|
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="grid grid-cols-3 gap-3 mb-5">
|
target="_blank"
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
rel="noopener noreferrer"
|
||||||
<p className="text-lg font-bold text-amber-600">
|
>
|
||||||
{formatNumber(dailyRequestLimit)}
|
<Image
|
||||||
</p>
|
src="/volcengine-invite.png"
|
||||||
<p className="text-xs text-gray-500">
|
alt="火山引擎方舟 Coding Plan"
|
||||||
请求/天
|
width={300}
|
||||||
</p>
|
height={400}
|
||||||
</div>
|
className="mx-auto rounded-lg"
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
/>
|
||||||
<p className="text-lg font-bold text-amber-600">
|
</a>
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Token/天
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Token/分钟
|
|
||||||
</p>
|
|
||||||
</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 */}
|
||||||
@@ -227,92 +175,106 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<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">
|
||||||
动画Transformer连接器
|
ResNet50模型架构动画
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>提示词:</strong> 给我一个带有
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>动画连接器</strong>的Transformer架构图。
|
<strong>animated</strong> architecture diagram
|
||||||
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="带动画连接器的Transformer架构"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50模型架构图"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram 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">
|
||||||
GCP架构图
|
RAG技术图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCP图标</strong>
|
architecture diagram for{" "}
|
||||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP架构图"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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">
|
||||||
AWS架构图
|
React和AWS认证流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWS图标</strong>
|
authentication process using React with{" "}
|
||||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS架构图"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="认证架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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架构图
|
敏捷Scrum流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azure图标</strong>
|
scrum workflow diagram for software
|
||||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure架构图"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="敏捷Scrum流程图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>提示词:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
给我画一只可爱的猫。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫咪绘图"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="开放式创新图"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +308,7 @@ export default function AboutCN() {
|
|||||||
<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>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -361,11 +323,13 @@ 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>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>{" "}
|
||||||
@@ -379,7 +343,7 @@ export default function AboutCN() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
特别感谢{" "}
|
特别感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
+76
-130
@@ -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,18 +17,7 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000) {
|
|
||||||
return `${num / 1000}k`
|
|
||||||
}
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutJA() {
|
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 */}
|
||||||
@@ -80,28 +69,6 @@ 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-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -119,7 +86,7 @@ export default function AboutJA() {
|
|||||||
<p>
|
<p>
|
||||||
朗報です!
|
朗報です!
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -128,7 +95,7 @@ export default function AboutJA() {
|
|||||||
</a>
|
</a>
|
||||||
様のご支援により、デモサイトでは強力な{" "}
|
様のご支援により、デモサイトでは強力な{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -138,42 +105,6 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
現在の使用制限:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyRequestLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
リクエスト/日
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
トークン/日
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
トークン/分
|
|
||||||
</p>
|
|
||||||
</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">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -237,93 +168,106 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<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">
|
||||||
アニメーションTransformerコネクタ
|
ResNet50モデルアーキテクチャアニメーション
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>アニメーションコネクタ</strong>
|
<strong>animated</strong> architecture diagram
|
||||||
付きのTransformerアーキテクチャ図を作成してください。
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50モデルアーキテクチャ図"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram 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">
|
||||||
GCPアーキテクチャ図
|
RAG技術ダイアグラム
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCPアイコン</strong>
|
architecture diagram for{" "}
|
||||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCPアーキテクチャ図"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAGアーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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">
|
||||||
AWSアーキテクチャ図
|
ReactとAWSによる認証
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWSアイコン</strong>
|
authentication process using React with{" "}
|
||||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWSアーキテクチャ図"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="認証アーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azureアイコン</strong>
|
scrum workflow diagram for software
|
||||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azureアーキテクチャ図"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="アジャイルスクラム図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
かわいい猫を描いてください。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫の絵"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="オープンイノベーション図"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,7 +305,7 @@ export default function AboutJA() {
|
|||||||
<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>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -376,11 +320,13 @@ 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>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>
|
||||||
@@ -394,7 +340,7 @@ export default function AboutJA() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
+75
-132
@@ -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,18 +17,7 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000) {
|
|
||||||
return `${num / 1000}k`
|
|
||||||
}
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function About() {
|
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 */}
|
||||||
@@ -80,28 +69,6 @@ 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-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -120,7 +87,7 @@ export default function About() {
|
|||||||
Great news! Thanks to the generous
|
Great news! Thanks to the generous
|
||||||
sponsorship from{" "}
|
sponsorship from{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -129,7 +96,7 @@ export default function About() {
|
|||||||
</a>
|
</a>
|
||||||
, the demo site now uses the powerful{" "}
|
, the demo site now uses the powerful{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
model for better diagram generation! Sign up
|
model for better diagram generation! Sign up
|
||||||
via the link to get{" "}
|
via the link to get{" "}
|
||||||
@@ -140,42 +107,6 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Please note the current usage limits:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyRequestLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
requests/day
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
tokens/day
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
tokens/min
|
|
||||||
</p>
|
|
||||||
</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">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -251,96 +182,106 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<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 Transformer Connectors
|
Animated ResNet50 Model Architecture
|
||||||
</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 connector</strong> diagram of
|
<strong>animated</strong> architecture diagram
|
||||||
transformer's architecture.
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="Transformer Architecture with Animated Connectors"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="Architecture diagram for ResNet50 model"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram 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">
|
||||||
GCP Architecture Diagram
|
RAG Technique 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 GCP
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
architecture diagram with{" "}
|
architecture diagram for{" "}
|
||||||
<strong>GCP icons</strong>. Users connect to
|
<strong>chat application</strong>. Use
|
||||||
a frontend hosted on an instance.
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP Architecture Diagram"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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">
|
||||||
AWS Architecture Diagram
|
Authentication using React and 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 an AWS
|
<strong>Prompt:</strong> Generate
|
||||||
architecture diagram with{" "}
|
authentication process using React with{" "}
|
||||||
<strong>AWS icons</strong>. Users connect to
|
<strong>AWS</strong>. Use Serverless
|
||||||
a frontend hosted on an instance.
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS Architecture Diagram"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="Authentication Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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 Architecture Diagram
|
Agile Scrum Process
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an Azure
|
<strong>Prompt:</strong> Generate agile
|
||||||
architecture diagram with{" "}
|
scrum workflow diagram for software
|
||||||
<strong>Azure icons</strong>. Users connect
|
development team.
|
||||||
to a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure Architecture Diagram"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="Agile Scrum Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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">
|
||||||
Cat Sketch
|
Open Innovation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Draw a cute cat for
|
<strong>Prompt:</strong> Create
|
||||||
me.
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="Cat Drawing"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="Open Innovation Diagram"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,7 +321,7 @@ export default function About() {
|
|||||||
<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>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -395,11 +336,13 @@ 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>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
|
||||||
@@ -415,7 +358,7 @@ export default function About() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
Special thanks to{" "}
|
Special thanks to{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||||
|
import { ProviderLogo } from "@/components/provider-logo"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import {
|
||||||
|
FIXED_CRED_PROVIDERS,
|
||||||
|
generateId,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderName,
|
||||||
|
SUGGESTED_MODELS,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type AdminProvider,
|
||||||
|
adminFetch,
|
||||||
|
type EnvProvider,
|
||||||
|
} from "./admin-shared"
|
||||||
|
import { SecretInput } from "./setting-field"
|
||||||
|
|
||||||
|
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
||||||
|
|
||||||
|
function ProviderDetail({
|
||||||
|
provider,
|
||||||
|
disabled,
|
||||||
|
password,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
provider: AdminProvider
|
||||||
|
disabled: boolean
|
||||||
|
password: string
|
||||||
|
onUpdate: (patch: Partial<AdminProvider>) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [modelInput, setModelInput] = useState("")
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [testing, setTesting] = useState<string | null>(null)
|
||||||
|
const [testResults, setTestResults] = useState<
|
||||||
|
Record<string, { ok: boolean; message: string }>
|
||||||
|
>({})
|
||||||
|
|
||||||
|
const info = PROVIDER_INFO[provider.provider]
|
||||||
|
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||||
|
(m) => !provider.models.includes(m),
|
||||||
|
)
|
||||||
|
|
||||||
|
const addModel = (modelId: string) => {
|
||||||
|
const trimmed = modelId.trim()
|
||||||
|
if (!trimmed || provider.models.includes(trimmed)) return
|
||||||
|
onUpdate({ models: [...provider.models, trimmed] })
|
||||||
|
setModelInput("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const testModel = async (modelId: string) => {
|
||||||
|
setTesting(modelId)
|
||||||
|
try {
|
||||||
|
const data = await adminFetch("/api/admin/test-model", password, {
|
||||||
|
method: "POST",
|
||||||
|
// EdgeOne's function also checks the access code
|
||||||
|
headers: {
|
||||||
|
"x-access-code":
|
||||||
|
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ provider, modelId }),
|
||||||
|
})
|
||||||
|
setTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[modelId]: data.valid
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
message: formatMessage(dict.admin.testOk, {
|
||||||
|
ms: data.responseTime,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
message: data.error || dict.admin.testFailed,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[modelId]: {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: dict.admin.testFailed,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
setTesting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<ProviderLogo
|
||||||
|
provider={provider.provider}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold">{info.label}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{provider.models.length === 0
|
||||||
|
? dict.admin.noModelsConfigured
|
||||||
|
: formatMessage(
|
||||||
|
provider.models.length === 1
|
||||||
|
? dict.admin.modelCount
|
||||||
|
: dict.admin.modelCountPlural,
|
||||||
|
{ count: provider.models.length },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
provider.isDefault &&
|
||||||
|
"fill-amber-400 text-amber-400",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.default}
|
||||||
|
<Switch
|
||||||
|
checked={!!provider.isDefault}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={dict.admin.setAsDefault}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdate({ isDefault: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
{dict.admin.delete}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||||
|
<ProviderCredentialsFields
|
||||||
|
provider={provider.provider}
|
||||||
|
name={provider.name}
|
||||||
|
baseUrl={provider.baseUrl}
|
||||||
|
awsRegion={provider.awsRegion}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||||
|
renderSecret={({ field, id }) => (
|
||||||
|
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||||
|
// associated; only one ProviderDetail is mounted at a time.
|
||||||
|
<SecretInput
|
||||||
|
id={id}
|
||||||
|
keepOnEmpty
|
||||||
|
value={provider[field]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => onUpdate({ [field]: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Models */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{dict.admin.models}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={modelInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={dict.admin.modelIdPlaceholder}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-8 w-48 font-mono text-xs"
|
||||||
|
onChange={(e) => setModelInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") addModel(modelInput)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={disabled || !modelInput.trim()}
|
||||||
|
aria-label={dict.admin.addModel}
|
||||||
|
onClick={() => addModel(modelInput)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => addModel(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-28 text-xs">
|
||||||
|
{dict.admin.suggested}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{suggestions.map((m) => (
|
||||||
|
<SelectItem
|
||||||
|
key={m}
|
||||||
|
value={m}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
{provider.models.length === 0 ? (
|
||||||
|
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||||
|
{dict.admin.addProviderToOfferModels}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{provider.models.map((modelId, index) => {
|
||||||
|
const result = testResults[modelId]
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={modelId}
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{modelId}
|
||||||
|
{provider.isDefault &&
|
||||||
|
index === 0 && (
|
||||||
|
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.defaultModel
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{result && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs",
|
||||||
|
result.ok
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.ok ? (
|
||||||
|
<Check
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AlertCircle
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-48 truncate">
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={
|
||||||
|
disabled || testing !== null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void testModel(modelId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{testing === modelId ? (
|
||||||
|
<Loader2
|
||||||
|
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Zap
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="ml-1">
|
||||||
|
{dict.admin.test}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={formatMessage(
|
||||||
|
dict.admin.removeModel,
|
||||||
|
{ model: modelId },
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
onUpdate({
|
||||||
|
models: provider.models.filter(
|
||||||
|
(m) => m !== modelId,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||||
|
name: provider.name || info.label,
|
||||||
|
})}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.admin.deleteProviderDesc}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.admin.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dict.admin.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelsSection({
|
||||||
|
providers,
|
||||||
|
envProviders,
|
||||||
|
envHasDefaultModel,
|
||||||
|
disabled,
|
||||||
|
password,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
providers: AdminProvider[]
|
||||||
|
envProviders: EnvProvider[]
|
||||||
|
envHasDefaultModel: boolean
|
||||||
|
disabled: boolean
|
||||||
|
password: string
|
||||||
|
onChange: (providers: AdminProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
providers[0]?.id ?? null,
|
||||||
|
)
|
||||||
|
const selected = providers.find((p) => p.id === selectedId)
|
||||||
|
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||||
|
|
||||||
|
const addProvider = (provider: ProviderName) => {
|
||||||
|
const newProvider: AdminProvider = {
|
||||||
|
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||||
|
id: generateId(),
|
||||||
|
provider,
|
||||||
|
models: [],
|
||||||
|
// Only the very first provider becomes the default, and only when
|
||||||
|
// the env config has no default that it would replace on save
|
||||||
|
isDefault:
|
||||||
|
providers.length === 0 &&
|
||||||
|
!envProviders.some((p) => p.isDefault) &&
|
||||||
|
!envHasDefaultModel,
|
||||||
|
}
|
||||||
|
onChange([...providers, newProvider])
|
||||||
|
setSelectedId(newProvider.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||||
|
onChange(
|
||||||
|
providers.map((p) => {
|
||||||
|
if (p.id !== id) {
|
||||||
|
// Only one default at a time
|
||||||
|
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||||
|
}
|
||||||
|
return { ...p, ...patch }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProvider = (id: string) => {
|
||||||
|
const next = providers.filter((p) => p.id !== id)
|
||||||
|
onChange(next)
|
||||||
|
setSelectedId(next[0]?.id ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||||
|
{/* Provider list */}
|
||||||
|
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||||
|
<div className="flex-1 space-y-1 p-2">
|
||||||
|
{providers.length === 0 && envProviders.length === 0 && (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{dict.admin.addProviderHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{envProviders.map((p) => (
|
||||||
|
<button
|
||||||
|
key={`env:${p.name}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
selectedId === `env:${p.name}` &&
|
||||||
|
"bg-muted font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderLogo provider={p.provider} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||||
|
{dict.admin.sourceEnv}
|
||||||
|
</span>
|
||||||
|
{p.isDefault && (
|
||||||
|
<Star
|
||||||
|
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||||
|
aria-label={dict.admin.defaultProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
selectedId === p.id && "bg-muted font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderLogo provider={p.provider} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{p.name || PROVIDER_INFO[p.provider].label}
|
||||||
|
</span>
|
||||||
|
{p.isDefault && (
|
||||||
|
<Star
|
||||||
|
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||||
|
aria-label={dict.admin.defaultProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t p-2">
|
||||||
|
{/* Always empty so picking the same type again still fires */}
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<Plus
|
||||||
|
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.modelConfig.addProvider}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||||
|
(p) => {
|
||||||
|
// Global-credential providers already in
|
||||||
|
// the env config can't be added here —
|
||||||
|
// panel credentials would override theirs
|
||||||
|
const envBlocked =
|
||||||
|
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||||
|
envProviders.some(
|
||||||
|
(e) => e.provider === p,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={p}
|
||||||
|
value={p}
|
||||||
|
disabled={envBlocked}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderLogo provider={p} />
|
||||||
|
{PROVIDER_INFO[p].label}
|
||||||
|
{envBlocked && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.managedViaEnv
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
<div className="min-w-0 flex-1 p-4">
|
||||||
|
{selected ? (
|
||||||
|
<ProviderDetail
|
||||||
|
key={selected.id}
|
||||||
|
provider={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
password={password}
|
||||||
|
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||||
|
onDelete={() => deleteProvider(selected.id)}
|
||||||
|
/>
|
||||||
|
) : selectedEnv ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<ProviderLogo
|
||||||
|
provider={selectedEnv.provider}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{selectedEnv.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.admin.envReadOnly}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<ul className="divide-y">
|
||||||
|
{selectedEnv.models.map((modelId, index) => (
|
||||||
|
<li
|
||||||
|
key={modelId}
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{modelId}
|
||||||
|
{selectedEnv.isDefault &&
|
||||||
|
index === 0 && (
|
||||||
|
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.defaultModel
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{dict.admin.selectProviderHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
SETTING_GROUPS,
|
||||||
|
SETTINGS_BY_GROUP,
|
||||||
|
} from "@/lib/admin/settings-registry"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type AdminProvider,
|
||||||
|
adminFetch,
|
||||||
|
type EnvProvider,
|
||||||
|
isSecretValue,
|
||||||
|
SESSION_PASSWORD_KEY,
|
||||||
|
type SettingState,
|
||||||
|
type SettingsMap,
|
||||||
|
savedTextOf,
|
||||||
|
} from "./admin-shared"
|
||||||
|
import { ModelsSection } from "./models-section"
|
||||||
|
import { SettingField } from "./setting-field"
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||||
|
|
||||||
|
// For each toggleable group, whether any of its settings has a value (from
|
||||||
|
// the settings file or the environment)
|
||||||
|
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
|
||||||
|
const result: Record<string, boolean> = {}
|
||||||
|
for (const group of SETTING_GROUPS) {
|
||||||
|
if (!group.toggleable) continue
|
||||||
|
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||||
|
(d) => map[d.key]?.source !== "default",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const dict = useDictionary()
|
||||||
|
// Localized group title/description, keyed by group id
|
||||||
|
const groupText = (id: string) =>
|
||||||
|
(
|
||||||
|
dict.admin.groups as Record<
|
||||||
|
string,
|
||||||
|
{ title: string; description: string } | undefined
|
||||||
|
>
|
||||||
|
)[id]
|
||||||
|
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
title:
|
||||||
|
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||||
|
}))
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||||
|
const [authError, setAuthError] = useState("")
|
||||||
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
|
|
||||||
|
const [writable, setWritable] = useState(true)
|
||||||
|
|
||||||
|
// Models section state
|
||||||
|
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||||
|
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||||
|
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||||
|
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||||
|
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||||
|
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||||
|
|
||||||
|
// General settings state
|
||||||
|
const [settings, setSettings] = useState<SettingsMap>({})
|
||||||
|
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveMessage, setSaveMessage] = useState<{
|
||||||
|
ok: boolean
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
const [activeGroup, setActiveGroup] = useState("models")
|
||||||
|
|
||||||
|
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||||
|
|
||||||
|
const applySettingsResponse = useCallback(
|
||||||
|
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||||
|
setWritable(data.writable)
|
||||||
|
const map: SettingsMap = {}
|
||||||
|
for (const s of data.settings) map[s.key] = s
|
||||||
|
setSettings(map)
|
||||||
|
// A group stays on while it still has values (e.g. from env vars
|
||||||
|
// that saving can't remove); a user's explicit "on" for a group
|
||||||
|
// with no values yet is kept across saves
|
||||||
|
setEnabledGroups((prev) => {
|
||||||
|
const next = groupsWithValues(map)
|
||||||
|
for (const id of Object.keys(next)) {
|
||||||
|
next[id] = next[id] || !!prev[id]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyProvidersResponse = useCallback(
|
||||||
|
(data: {
|
||||||
|
providers: AdminProvider[]
|
||||||
|
envProviders?: EnvProvider[]
|
||||||
|
envHasDefaultModel?: boolean
|
||||||
|
}) => {
|
||||||
|
setProviders(data.providers)
|
||||||
|
setSavedProviders(JSON.stringify(data.providers))
|
||||||
|
setEnvProviders(data.envProviders ?? [])
|
||||||
|
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (pw: string) => {
|
||||||
|
setAuthLoading(true)
|
||||||
|
setAuthError("")
|
||||||
|
try {
|
||||||
|
const [settingsData, providersData] = await Promise.all([
|
||||||
|
adminFetch("/api/admin/settings", pw),
|
||||||
|
adminFetch("/api/admin/providers", pw),
|
||||||
|
])
|
||||||
|
applySettingsResponse(settingsData)
|
||||||
|
applyProvidersResponse(providersData)
|
||||||
|
setAuthedPassword(pw)
|
||||||
|
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||||
|
} catch (err) {
|
||||||
|
setAuthError(
|
||||||
|
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applySettingsResponse, applyProvidersResponse, dict],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||||
|
if (stored) void login(stored)
|
||||||
|
}, [login])
|
||||||
|
|
||||||
|
// Warn before leaving with unsaved changes
|
||||||
|
const hasDirty = dirtyCount > 0
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDirty) return
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// Some browsers only show the prompt when returnValue is set
|
||||||
|
e.returnValue = ""
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handler)
|
||||||
|
return () => window.removeEventListener("beforeunload", handler)
|
||||||
|
}, [hasDirty])
|
||||||
|
|
||||||
|
// Highlight the section currently in view in the sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authedPassword) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||||
|
)
|
||||||
|
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||||
|
},
|
||||||
|
{ rootMargin: "-10% 0px -50% 0px" },
|
||||||
|
)
|
||||||
|
for (const id of NAV_GROUP_IDS) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
}
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [authedPassword])
|
||||||
|
|
||||||
|
// value undefined drops the pending change (back to the saved value)
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(key: string, value: string | null | undefined) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(key in prev)) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPending((prev) => {
|
||||||
|
const state = settings[key]
|
||||||
|
const isRevert =
|
||||||
|
value !== null &&
|
||||||
|
state?.source === "file" &&
|
||||||
|
!isSecretValue(state?.value) &&
|
||||||
|
value === savedTextOf(state)
|
||||||
|
const isNoop =
|
||||||
|
value === "" &&
|
||||||
|
(!state || state.source !== "file") &&
|
||||||
|
!isSecretValue(state?.value)
|
||||||
|
if (value === undefined || isRevert || isNoop) {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: value === "" ? null : value }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggling a group off stages deletion of its saved values so the
|
||||||
|
// feature actually turns off on save; toggling on drops those deletions.
|
||||||
|
const handleGroupToggle = useCallback(
|
||||||
|
(groupId: string, enabled: boolean) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||||
|
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||||
|
(d) => d.key,
|
||||||
|
)
|
||||||
|
setPending((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!enabled) {
|
||||||
|
// Stage deletion of saved values; drop unsaved input
|
||||||
|
if (settings[key]?.source === "default")
|
||||||
|
delete next[key]
|
||||||
|
else next[key] = null
|
||||||
|
} else if (next[key] === null) {
|
||||||
|
delete next[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!authedPassword || dirtyCount === 0) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveMessage(null)
|
||||||
|
setErrors({})
|
||||||
|
try {
|
||||||
|
if (providersDirty) {
|
||||||
|
const data = await adminFetch(
|
||||||
|
"/api/admin/providers",
|
||||||
|
authedPassword,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||||
|
)
|
||||||
|
applyProvidersResponse(data)
|
||||||
|
}
|
||||||
|
if (Object.keys(pending).length > 0) {
|
||||||
|
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-admin-password": authedPassword,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ values: pending }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
// Per-field validation errors come back as {errors: {...}}
|
||||||
|
if (data.errors) {
|
||||||
|
setErrors(data.errors)
|
||||||
|
const firstKey = Object.keys(data.errors)[0]
|
||||||
|
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||||
|
throw new Error(dict.admin.invalidSettings)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
data.error || `Request failed (${res.status})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applySettingsResponse(data)
|
||||||
|
setPending({})
|
||||||
|
}
|
||||||
|
setSaveMessage({
|
||||||
|
ok: true,
|
||||||
|
text: dict.admin.saved,
|
||||||
|
})
|
||||||
|
setTimeout(() => setSaveMessage(null), 4000)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveMessage({
|
||||||
|
ok: false,
|
||||||
|
text:
|
||||||
|
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
authedPassword,
|
||||||
|
pending,
|
||||||
|
providers,
|
||||||
|
providersDirty,
|
||||||
|
dirtyCount,
|
||||||
|
applySettingsResponse,
|
||||||
|
applyProvidersResponse,
|
||||||
|
dict,
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Login screen ─────────────────────────────────────────────────
|
||||||
|
if (!authedPassword) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void login(password)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LockKeyhole
|
||||||
|
className="h-5 w-5 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{dict.admin.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{dict.admin.loginPrompt}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="admin-password">
|
||||||
|
{dict.admin.password}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="admin-password"
|
||||||
|
name="admin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-destructive",
|
||||||
|
!authError && "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{authError}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={authLoading}
|
||||||
|
>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.signingIn}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
dict.admin.signIn
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings screen ──────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck
|
||||||
|
className="h-5 w-5 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{dict.admin.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.admin.precedence}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!writable && (
|
||||||
|
<div className="border-b bg-amber-500/10">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.notWritable}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||||
|
<nav
|
||||||
|
aria-label={dict.admin.settingGroups}
|
||||||
|
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||||
|
>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
aria-current={
|
||||||
|
activeGroup === item.id
|
||||||
|
? "true"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
activeGroup === item.id
|
||||||
|
? "bg-muted font-medium text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 pb-24">
|
||||||
|
{/* Models section */}
|
||||||
|
<section aria-labelledby="models" className="mb-10">
|
||||||
|
<h2
|
||||||
|
id="models"
|
||||||
|
className="scroll-mt-20 text-base font-semibold"
|
||||||
|
>
|
||||||
|
{dict.admin.models}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||||
|
{dict.admin.modelsDescription}
|
||||||
|
</p>
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-card">
|
||||||
|
<ModelsSection
|
||||||
|
providers={providers}
|
||||||
|
envProviders={envProviders}
|
||||||
|
envHasDefaultModel={envHasDefaultModel}
|
||||||
|
disabled={!writable || saving}
|
||||||
|
password={authedPassword}
|
||||||
|
onChange={(next) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setProviders(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Registry-driven groups */}
|
||||||
|
{SETTING_GROUPS.map((group) => {
|
||||||
|
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||||
|
const groupOff =
|
||||||
|
group.toggleable && !enabledGroups[group.id]
|
||||||
|
// Values from env vars can't be removed here, so the
|
||||||
|
// group can't be turned off from the panel
|
||||||
|
const envLocked = defs.some(
|
||||||
|
(d) => settings[d.key]?.source === "env",
|
||||||
|
)
|
||||||
|
const fieldsDisabled = !writable || saving || !!groupOff
|
||||||
|
const gt = groupText(group.id)
|
||||||
|
const title = gt?.title ?? group.title
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={group.id}
|
||||||
|
aria-labelledby={group.id}
|
||||||
|
className="mb-10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2
|
||||||
|
id={group.id}
|
||||||
|
className="scroll-mt-20 text-base font-semibold"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{group.toggleable && (
|
||||||
|
<label
|
||||||
|
title={
|
||||||
|
envLocked
|
||||||
|
? dict.admin.sourceEnvTitle
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||||
|
enabledGroups[group.id]
|
||||||
|
? "border-primary/30 bg-primary/5 text-primary"
|
||||||
|
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enabledGroups[group.id]
|
||||||
|
? dict.admin.enabled
|
||||||
|
: dict.admin.disabled}
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
!!enabledGroups[group.id]
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!writable ||
|
||||||
|
saving ||
|
||||||
|
envLocked
|
||||||
|
}
|
||||||
|
aria-label={formatMessage(
|
||||||
|
dict.admin.enableGroup,
|
||||||
|
{ group: title },
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleGroupToggle(
|
||||||
|
group.id,
|
||||||
|
checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||||
|
{gt?.description ?? group.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card px-4",
|
||||||
|
groupOff &&
|
||||||
|
"pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{defs.map((def) => (
|
||||||
|
<SettingField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
state={settings[def.key]}
|
||||||
|
pendingValue={pending[def.key]}
|
||||||
|
error={errors[def.key]}
|
||||||
|
disabled={fieldsDisabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleChange(def.key, v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Always-mounted live region so save results are announced */}
|
||||||
|
<p aria-live="polite" className="sr-only">
|
||||||
|
{saveMessage?.text ?? ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(dirtyCount > 0 || saveMessage) && (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||||
|
saveMessage?.ok
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: saveMessage
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saveMessage?.ok && (
|
||||||
|
<Check
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{saveMessage && !saveMessage.ok
|
||||||
|
? saveMessage.text
|
||||||
|
: dirtyCount > 0
|
||||||
|
? dict.admin.unsavedChanges
|
||||||
|
: saveMessage?.text}
|
||||||
|
</p>
|
||||||
|
{dirtyCount > 0 && (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPending({})
|
||||||
|
setErrors({})
|
||||||
|
setProviders(JSON.parse(savedProviders))
|
||||||
|
setEnabledGroups(
|
||||||
|
groupsWithValues(settings),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dict.admin.discard}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !writable}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.saving}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
dict.admin.saveChanges
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { Eye, EyeOff, X } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
isSecretValue,
|
||||||
|
type SecretValue,
|
||||||
|
type SettingState,
|
||||||
|
savedTextOf,
|
||||||
|
} from "./admin-shared"
|
||||||
|
|
||||||
|
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
if (source === "default") return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||||
|
source === "file"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
source === "file"
|
||||||
|
? dict.admin.sourceSavedTitle
|
||||||
|
: dict.admin.sourceEnvTitle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartBadge() {
|
||||||
|
const dict = useDictionary()
|
||||||
|
return (
|
||||||
|
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||||
|
{dict.admin.restartRequired}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||||
|
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||||
|
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||||
|
export function SecretInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
keepOnEmpty,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: string | SecretValue | undefined
|
||||||
|
disabled?: boolean
|
||||||
|
keepOnEmpty?: boolean
|
||||||
|
onChange: (value: string | SecretValue) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||||
|
// passes server state (a marker or nothing), e.g. after a save.
|
||||||
|
const [original, setOriginal] = useState(value)
|
||||||
|
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||||
|
const hadStored = isSecretValue(original)
|
||||||
|
const text = typeof value === "string" ? value : ""
|
||||||
|
const placeholder = isSecretValue(value)
|
||||||
|
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||||
|
: dict.admin.notSet
|
||||||
|
const handleText = (t: string) => {
|
||||||
|
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||||
|
onChange(original)
|
||||||
|
} else {
|
||||||
|
onChange(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={show ? "text" : "password"}
|
||||||
|
value={text}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-9 font-mono text-xs"
|
||||||
|
onChange={(e) => handleText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||||
|
onClick={() => setShow((s) => !s)}
|
||||||
|
>
|
||||||
|
{show ? (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={dict.admin.removeValue}
|
||||||
|
title={dict.admin.removeValueTitle}
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── General settings field (registry-driven) ─────────────────────────
|
||||||
|
|
||||||
|
export function SettingField({
|
||||||
|
def,
|
||||||
|
state,
|
||||||
|
pendingValue,
|
||||||
|
error,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
def: SettingDef
|
||||||
|
state: SettingState | undefined
|
||||||
|
pendingValue: string | null | undefined
|
||||||
|
error?: string
|
||||||
|
disabled: boolean
|
||||||
|
// undefined drops the pending change (back to the saved value)
|
||||||
|
onChange: (value: string | null | undefined) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const isDirty = pendingValue !== undefined
|
||||||
|
const source = state?.source ?? "default"
|
||||||
|
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||||
|
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||||
|
|
||||||
|
// Localized label/description keyed by env var name, falling back to the
|
||||||
|
// registry's English (the registry stays canonical for the server).
|
||||||
|
const t = (
|
||||||
|
dict.admin.settings as Record<
|
||||||
|
string,
|
||||||
|
{ label?: string; description?: string } | undefined
|
||||||
|
>
|
||||||
|
)[def.key]
|
||||||
|
const label = t?.label ?? def.label
|
||||||
|
const description = t?.description ?? def.description
|
||||||
|
|
||||||
|
const inputId = `setting-${def.key}`
|
||||||
|
const errorId = `${inputId}-error`
|
||||||
|
|
||||||
|
let control: React.ReactNode
|
||||||
|
switch (def.type) {
|
||||||
|
case "boolean": {
|
||||||
|
// When unset, reflect the built-in runtime default so the toggle
|
||||||
|
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||||
|
const effective =
|
||||||
|
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||||
|
// A saved boolean can be cleared back to its env/default value.
|
||||||
|
const canClear =
|
||||||
|
(isDirty && pendingValue !== null) || source === "file"
|
||||||
|
control = (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id={inputId}
|
||||||
|
checked={effective === "true"}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange(checked ? "true" : "false")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{canClear && !disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
{dict.admin.resetToDefault}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "enum":
|
||||||
|
control = (
|
||||||
|
<Select
|
||||||
|
value={currentValue || undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||||
|
<SelectValue placeholder={dict.admin.notSet} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{def.options?.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "secret":
|
||||||
|
control = (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||||
|
<SecretInput
|
||||||
|
id={inputId}
|
||||||
|
keepOnEmpty={source === "file"}
|
||||||
|
value={
|
||||||
|
isDirty
|
||||||
|
? (pendingValue ?? "")
|
||||||
|
: (secretState ?? undefined)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
onChange(typeof v === "string" ? v : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "number":
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={def.min}
|
||||||
|
max={def.max}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||||
|
className="w-full max-w-xs tabular-nums"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<SourceChip source={source} />
|
||||||
|
{def.restartRequired && <RestartBadge />}
|
||||||
|
{isDirty && (
|
||||||
|
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
{dict.admin.modified}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{control}
|
||||||
|
<p
|
||||||
|
id={errorId}
|
||||||
|
className={cn(
|
||||||
|
"text-xs text-destructive",
|
||||||
|
error ? "mt-1.5" : "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{error ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
-2
@@ -41,19 +41,24 @@ 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 = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
const lang = (
|
||||||
|
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 {
|
||||||
@@ -80,7 +85,14 @@ 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: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
locale:
|
||||||
|
lang === "zh"
|
||||||
|
? "zh_CN"
|
||||||
|
: lang === "zh-Hant"
|
||||||
|
? "zh_HK"
|
||||||
|
: lang === "ja"
|
||||||
|
? "ja_JP"
|
||||||
|
: "en_US",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
@@ -115,6 +127,7 @@ export async function generateMetadata({
|
|||||||
en: "/en",
|
en: "/en",
|
||||||
zh: "/zh",
|
zh: "/zh",
|
||||||
ja: "/ja",
|
ja: "/ja",
|
||||||
|
"zh-Hant": "/zh-Hant",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-113
@@ -1,64 +1,42 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
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"
|
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 router = useRouter()
|
||||||
const pathname = usePathname()
|
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<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [closeProtection, setCloseProtection] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = 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)
|
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -69,13 +47,15 @@ export default function Home() {
|
|||||||
const currentLocale = pathParts[0]
|
const currentLocale = pathParts[0]
|
||||||
if (currentLocale !== savedLocale) {
|
if (currentLocale !== savedLocale) {
|
||||||
pathParts[0] = savedLocale
|
pathParts[0] = savedLocale
|
||||||
router.replace(`/${pathParts.join("/")}`)
|
// Keep the query (e.g. ?session=) and hash
|
||||||
|
const { search, hash } = window.location
|
||||||
|
router.replace(`/${pathParts.join("/")}${search}${hash}`)
|
||||||
return // Wait for redirect
|
return // Wait for redirect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (savedUi === "min" || savedUi === "sketch") {
|
if (isDrawioTheme(savedUi)) {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,54 +72,60 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedCloseProtection = localStorage.getItem(
|
// Detect Electron and use bundled draw.io files for offline use
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||||
)
|
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||||
if (savedCloseProtection === "true") {
|
const electronDetected =
|
||||||
setCloseProtection(true)
|
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||||
|
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||||
|
if (electronDetected) {
|
||||||
|
setIsElectron(true)
|
||||||
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDrawioLoad = useCallback(() => {
|
||||||
await saveDiagramToStorage()
|
setIsDrawioReady(true)
|
||||||
|
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 = async () => {
|
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||||
await saveDiagramToStorage()
|
localStorage.setItem("drawio-theme", theme)
|
||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
setDrawioUi(theme)
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
setIsDrawioReady(false)
|
||||||
setDrawioUi(newUi)
|
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
// Check mobile. No panel is remounted when crossing the breakpoint, so
|
||||||
const isInitialRenderRef = useRef(true)
|
// the draw.io ready state and the chat's turn stay as they are.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
const newIsMobile = window.innerWidth < 768
|
setIsMobile(window.innerWidth < 768)
|
||||||
if (
|
|
||||||
!isInitialRenderRef.current &&
|
|
||||||
newIsMobile !== isMobileRef.current
|
|
||||||
) {
|
|
||||||
saveDiagramToStorage().catch(() => {})
|
|
||||||
resetDrawioReady()
|
|
||||||
}
|
|
||||||
isMobileRef.current = newIsMobile
|
|
||||||
isInitialRenderRef.current = false
|
|
||||||
setIsMobile(newIsMobile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [saveDiagramToStorage, resetDrawioReady])
|
}, [])
|
||||||
|
|
||||||
|
// Give the chat panel the size of this side of the breakpoint. It is
|
||||||
|
// open on both sides: the mobile panel cannot be collapsed, and one
|
||||||
|
// collapsed on desktop comes back open
|
||||||
|
useEffect(() => {
|
||||||
|
chatPanelRef.current?.resize(isMobile ? 50 : 33)
|
||||||
|
setIsChatVisible(true)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -167,20 +153,6 @@ 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
|
||||||
@@ -197,34 +169,47 @@ 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">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded ? (
|
{isLoaded && (
|
||||||
<DrawIoEmbed
|
<div
|
||||||
key={`${drawioUi}-${darkMode}`}
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
ref={drawioRef}
|
>
|
||||||
onExport={handleDiagramExport}
|
<DrawIoEmbed
|
||||||
onLoad={onDrawioLoad}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
onSave={handleDrawioSave}
|
ref={drawioRef}
|
||||||
baseUrl={drawioBaseUrl}
|
autosave
|
||||||
urlParameters={{
|
onAutoSave={handleDiagramAutoSave}
|
||||||
ui: drawioUi,
|
onExport={handleDiagramExport}
|
||||||
spin: true,
|
onLoad={handleDrawioLoad}
|
||||||
libraries: false,
|
baseUrl={drawioBaseUrl}
|
||||||
saveAndExit: false,
|
urlParameters={{
|
||||||
noExitBtn: true,
|
ui: drawioUi,
|
||||||
dark: darkMode,
|
spin: false,
|
||||||
}}
|
libraries: false,
|
||||||
/>
|
saveAndExit: false,
|
||||||
) : (
|
noSaveBtn: true,
|
||||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
noExitBtn: true,
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
dark:
|
||||||
|
darkMode || drawioUi === "dark",
|
||||||
|
// draw.io names Traditional Chinese "zh-tw"
|
||||||
|
lang:
|
||||||
|
currentLang === "zh-Hant"
|
||||||
|
? "zh-tw"
|
||||||
|
: 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>
|
||||||
@@ -235,7 +220,6 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
key={isMobile ? "mobile" : "desktop"}
|
|
||||||
id="chat-panel"
|
id="chat-panel"
|
||||||
ref={chatPanelRef}
|
ref={chatPanelRef}
|
||||||
defaultSize={isMobile ? 50 : 33}
|
defaultSize={isMobile ? 50 : 33}
|
||||||
@@ -247,16 +231,23 @@ 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"}`}>
|
||||||
<ChatPanel
|
<Suspense
|
||||||
isVisible={isChatVisible}
|
fallback={
|
||||||
onToggleVisibility={toggleChatPanel}
|
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
||||||
drawioUi={drawioUi}
|
Loading chat...
|
||||||
onToggleDrawioUi={handleDrawioUiChange}
|
</div>
|
||||||
darkMode={darkMode}
|
}
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
>
|
||||||
isMobile={isMobile}
|
<ChatPanel
|
||||||
onCloseProtectionChange={setCloseProtection}
|
isVisible={isChatVisible}
|
||||||
/>
|
onToggleVisibility={toggleChatPanel}
|
||||||
|
drawioUi={drawioUi}
|
||||||
|
onDrawioUiChange={handleDrawioUiChange}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
AdminProvidersSchema,
|
||||||
|
deriveEnvUpdates,
|
||||||
|
loadAdminProviders,
|
||||||
|
maskAdminProviders,
|
||||||
|
mergeSecrets,
|
||||||
|
validateAdminProviders,
|
||||||
|
} from "@/lib/admin/providers"
|
||||||
|
import {
|
||||||
|
getEnvFallback,
|
||||||
|
isSettingsWritable,
|
||||||
|
saveSettings,
|
||||||
|
} from "@/lib/admin/settings"
|
||||||
|
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
async function payload() {
|
||||||
|
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
||||||
|
// read-only in the panel; their credentials live in the environment
|
||||||
|
const envConfig = await loadEnvServerModelsConfig()
|
||||||
|
const adminProviders = loadAdminProviders()
|
||||||
|
// A panel default overrides any env default (matches the merge in
|
||||||
|
// loadRawServerModelsConfig), so env stars must reflect that
|
||||||
|
const adminHasDefault = adminProviders.some(
|
||||||
|
(p) => p.isDefault && p.models.length > 0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
writable: isSettingsWritable(),
|
||||||
|
providers: maskAdminProviders(adminProviders),
|
||||||
|
envProviders:
|
||||||
|
envConfig?.providers.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
provider: p.provider,
|
||||||
|
models: p.models,
|
||||||
|
isDefault: !!p.default && !adminHasDefault,
|
||||||
|
})) ?? [],
|
||||||
|
// Whether .env sets a default model. getEnvFallback skips the value
|
||||||
|
// the panel overlays onto process.env, so a panel default doesn't count.
|
||||||
|
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
return Response.json(await payload())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
if (!isSettingsWritable()) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AdminProvidersSchema.safeParse(
|
||||||
|
(body as { providers?: unknown })?.providers,
|
||||||
|
)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = loadAdminProviders()
|
||||||
|
const merged = mergeSecrets(parsed.data, stored)
|
||||||
|
|
||||||
|
const envConfig = await loadEnvServerModelsConfig()
|
||||||
|
const validationError = validateAdminProviders(merged, envConfig)
|
||||||
|
if (validationError) {
|
||||||
|
return Response.json({ error: validationError }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(deriveEnvUpdates(merged, stored))
|
||||||
|
|
||||||
|
return Response.json(await payload())
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
AdminProviderSchema,
|
||||||
|
loadAdminProviders,
|
||||||
|
mergeSecrets,
|
||||||
|
} from "@/lib/admin/providers"
|
||||||
|
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
// Test a model with the client's CURRENT provider state (which may be
|
||||||
|
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
||||||
|
// masked {isSet} markers, which are resolved against settings.json — so
|
||||||
|
// testing works both before and after saving.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
let body: { provider?: unknown; modelId?: string }
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AdminProviderSchema.safeParse(body.provider)
|
||||||
|
if (!parsed.success || !body.modelId) {
|
||||||
|
return Response.json(
|
||||||
|
{ valid: false, error: "Invalid provider or model" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
||||||
|
// the endpoint it would be sent to (provider + baseUrl) still matches
|
||||||
|
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
||||||
|
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
||||||
|
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
||||||
|
const sameEndpoint =
|
||||||
|
stored &&
|
||||||
|
stored.provider === parsed.data.provider &&
|
||||||
|
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
||||||
|
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
||||||
|
const [resolved] = mergeSecrets(
|
||||||
|
[parsed.data],
|
||||||
|
sameEndpoint && stored ? [stored] : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const serverUrl = globalBaseUrl(resolved.provider)
|
||||||
|
return validateModel(
|
||||||
|
new Request(new URL("/api/validate-model", req.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Checked again there, in place of an access code
|
||||||
|
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||||
|
// The EdgeOne function checks the access code and Pages
|
||||||
|
// cookies, and its URL is built from the page's origin
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
cookie: req.headers.get("cookie") || "",
|
||||||
|
...(req.headers.get("origin") && {
|
||||||
|
origin: req.headers.get("origin") as string,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: resolved.provider,
|
||||||
|
apiKey: resolved.apiKey,
|
||||||
|
// Without a URL of its own, chat sends the entry's key to
|
||||||
|
// the server's <P>_BASE_URL: test that endpoint, not
|
||||||
|
// another one. It is the server's own, which chat uses
|
||||||
|
// without the checks for a URL a user typed.
|
||||||
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
|
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||||
|
modelId: body.modelId,
|
||||||
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
|
awsRegion: resolved.awsRegion,
|
||||||
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
+440
-430
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
|||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
format: z.enum(["drawio", "png", "svg"]),
|
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
||||||
sessionId: z.string().min(1).max(200).optional(),
|
sessionId: z.string().min(1).max(200).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { extractFromHtml } from "@extractus/article-extractor"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import TurndownService from "turndown"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||||
|
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
|
||||||
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
|
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||||
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||||
|
|
||||||
|
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||||
|
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||||
|
// would produce mojibake; the article-extractor library does the same detection
|
||||||
|
// when it fetches the page itself, which we no longer rely on.
|
||||||
|
function detectCharset(
|
||||||
|
contentType: string | null,
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
): string {
|
||||||
|
// 1. HTTP Content-Type header charset (most authoritative).
|
||||||
|
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||||
|
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||||
|
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||||
|
const metaCharset =
|
||||||
|
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||||
|
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||||
|
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||||
|
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||||
|
try {
|
||||||
|
new TextDecoder(charset)
|
||||||
|
return charset
|
||||||
|
} catch {
|
||||||
|
return "utf-8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url } = await req.json()
|
||||||
|
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "URL is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid URL format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF protection: parse-url has no use case for fetching internal
|
||||||
|
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||||
|
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||||
|
if (await isPrivateUrl(url)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot access private/internal URLs" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Fetch the page ourselves so we control redirect handling. The
|
||||||
|
// article-extractor library follows redirects internally and ignores a
|
||||||
|
// `redirect` option, which would let a public URL 302 to an internal
|
||||||
|
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||||
|
// redirect outright.
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let html: string
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
redirect: "error",
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type")
|
||||||
|
if (contentType?.includes("application/pdf")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
|
||||||
|
if (!buffer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
||||||
|
},
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const charset = detectCharset(contentType, buffer)
|
||||||
|
html = new TextDecoder(charset).decode(buffer)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Timed out while fetching URL content" },
|
||||||
|
{ status: 504 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||||
|
// "unexpected redirect") when redirect: "error" is set.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
// Ends a download left unread (too large, PDF, error status);
|
||||||
|
// a body already read is not affected
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||||
|
// so map any parse error to the same 400 as the no-content case.
|
||||||
|
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||||
|
try {
|
||||||
|
article = await extractFromHtml(html, url)
|
||||||
|
} catch {
|
||||||
|
article = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!article || !article.content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not extract content from URL" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert HTML to Markdown
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: "atx",
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove unwanted elements before conversion
|
||||||
|
turndownService.remove(["script", "style", "iframe", "noscript"])
|
||||||
|
|
||||||
|
const markdown = turndownService.turndown(article.content)
|
||||||
|
|
||||||
|
// Check content length
|
||||||
|
if (markdown.length > MAX_CONTENT_LENGTH) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
title: article.title || "Untitled",
|
||||||
|
content: markdown,
|
||||||
|
charCount: markdown.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("URL extraction error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch or parse URL content" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { classifyLLMError } from "@/lib/llm-errors"
|
||||||
|
import {
|
||||||
|
canListModels,
|
||||||
|
listProviderModels,
|
||||||
|
ModelListError,
|
||||||
|
} from "@/lib/provider-models"
|
||||||
|
import {
|
||||||
|
allowPrivateUrls,
|
||||||
|
isPrivateUrl,
|
||||||
|
RedirectRefusedError,
|
||||||
|
redirectGuardedFetch,
|
||||||
|
} from "@/lib/ssrf-protection"
|
||||||
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
// Public lists need no key
|
||||||
|
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||||
|
"ollama",
|
||||||
|
"openrouter",
|
||||||
|
"aihubmix",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models a provider offers, for the "Fetch models" button in model
|
||||||
|
* settings. Answers { models: null } for providers that cannot list them,
|
||||||
|
* so the dialog keeps its suggested models.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Sends requests to a URL the client chose, so require the access code
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
||||||
|
provider: ProviderName
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
|
if (!canListModels(provider)) {
|
||||||
|
return NextResponse.json({ models: null })
|
||||||
|
}
|
||||||
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
|
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||||
|
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await listProviderModels(
|
||||||
|
provider,
|
||||||
|
{ apiKey, baseUrl },
|
||||||
|
(baseUrl && redirectGuardedFetch()) || fetch,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ models })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[provider-models] Listing failed:", error)
|
||||||
|
// Only our own explanations go back: the URL may be an internal
|
||||||
|
// address, whose answer or host names must not reach the caller.
|
||||||
|
// The Gateway SDK wraps them, keeping ours as the cause.
|
||||||
|
const isOwn = (e: unknown): e is Error =>
|
||||||
|
e instanceof ModelListError || e instanceof RedirectRefusedError
|
||||||
|
const cause = (error as { cause?: unknown })?.cause
|
||||||
|
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
||||||
|
const { code } = classifyLLMError(own ?? error)
|
||||||
|
return NextResponse.json({
|
||||||
|
code,
|
||||||
|
error: own?.message ?? "The model list request failed.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint for VLM-based diagram validation.
|
||||||
|
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Output, streamText } from "ai"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
|
import {
|
||||||
|
checkAndIncrementRequest,
|
||||||
|
isQuotaEnabled,
|
||||||
|
recordTokenUsage,
|
||||||
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
|
import {
|
||||||
|
type ValidationResult,
|
||||||
|
ValidationResultSchema,
|
||||||
|
} from "@/lib/validation-schema"
|
||||||
|
|
||||||
|
export const maxDuration = 30
|
||||||
|
|
||||||
|
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
|
||||||
|
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
interface ValidateDiagramRequest {
|
||||||
|
imageData: string // Base64 PNG data URL
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default valid result for disabled/error cases
|
||||||
|
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
suggestions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed result in the text format useObject reads */
|
||||||
|
function createStreamingResponse(result: ValidationResult): Response {
|
||||||
|
return new Response(JSON.stringify(result), {
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Uses the server's model credentials, so require the access code
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if VLM validation is enabled (default: true)
|
||||||
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
|
if (!enableValidation) {
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
|
const { imageData, sessionId } = body
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing imageData" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image data format
|
||||||
|
if (
|
||||||
|
!imageData.startsWith("data:image/png;base64,") &&
|
||||||
|
!imageData.startsWith("data:image/")
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid image data format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Image data too large" },
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It runs the server's vision model: with the quota on, the daily
|
||||||
|
// and per-minute token limits apply, and its tokens are counted. Not
|
||||||
|
// the request limit, which is for chats: the day's last chat still
|
||||||
|
// gets its check, and a check does not count as a chat.
|
||||||
|
const userId = getUserIdFromRequest(req)
|
||||||
|
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
|
||||||
|
if (countsQuota) {
|
||||||
|
const quotaCheck = await checkAndIncrementRequest(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
requests: 0,
|
||||||
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
|
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (!quotaCheck.allowed) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: quotaCheck.error,
|
||||||
|
type: quotaCheck.type,
|
||||||
|
used: quotaCheck.used,
|
||||||
|
limit: quotaCheck.limit,
|
||||||
|
},
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the validation model
|
||||||
|
let model
|
||||||
|
try {
|
||||||
|
model = getValidationModel()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[validate-diagram] Validation model not available:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
// Return valid if no vision model is configured
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
|
const timeout =
|
||||||
|
Math.max(
|
||||||
|
1000,
|
||||||
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
|
) || 10000
|
||||||
|
|
||||||
|
// Stream the VLM response for useObject consumption
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
output: Output.object({ schema: ValidationResultSchema }),
|
||||||
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
image: imageData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Please analyze this diagram for visual quality issues.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
abortSignal: AbortSignal.timeout(timeout),
|
||||||
|
onFinish: ({ output, totalUsage }) => {
|
||||||
|
if (countsQuota && totalUsage) {
|
||||||
|
recordTokenUsage(
|
||||||
|
userId,
|
||||||
|
(totalUsage.inputTokens || 0) +
|
||||||
|
(totalUsage.outputTokens || 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sessionId && output) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toTextStreamResponse()
|
||||||
|
} catch (error) {
|
||||||
|
// Log with session context if available
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
|
// On error, return valid to not block the user
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
-226
@@ -1,78 +1,28 @@
|
|||||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
import { streamText, tool } from "ai"
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
|
||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|
||||||
import { generateText } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { z } from "zod"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
edgeOneEndpoint,
|
||||||
|
getAIModel,
|
||||||
|
globalBaseUrl,
|
||||||
|
usesServerCredentials,
|
||||||
|
usesServerEndpoint,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
|
import {
|
||||||
|
checkAndIncrementRequest,
|
||||||
|
isQuotaEnabled,
|
||||||
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { classifyLLMError } from "@/lib/llm-errors"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
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: ProviderName
|
||||||
apiKey: string
|
apiKey: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
modelId: string
|
modelId: string
|
||||||
@@ -80,19 +30,44 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string
|
||||||
|
// Vertex AI specific
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
|
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||||
|
serverBaseUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
// Drawing works through tool calls, so the test asks for one
|
||||||
|
const PING_TOOL = tool({
|
||||||
|
description: "Report that the connection works.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const NO_TOOL_CALL_WARNING =
|
||||||
|
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Lets the server send requests to arbitrary URLs, so require the access
|
||||||
|
// code, or the admin password (the admin panel's Test button)
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError && checkAdminAuth(req)) return accessError
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
const body: ValidateRequest = await req.json()
|
||||||
const {
|
const {
|
||||||
provider,
|
provider,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
|
||||||
modelId,
|
modelId,
|
||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
|
awsSessionToken,
|
||||||
|
// Note: Express Mode only needs vertexApiKey
|
||||||
|
vertexApiKey,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -101,9 +76,26 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// EdgeOne is this site's own function, as in the chat; the admin
|
||||||
|
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||||
|
const baseUrl =
|
||||||
|
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||||
|
// The admin panel's Test of an entry without a URL sends the
|
||||||
|
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||||
|
// user chose, so no private-address or redirect rules
|
||||||
|
const serverUrl =
|
||||||
|
body.serverBaseUrl === true &&
|
||||||
|
!!baseUrl &&
|
||||||
|
baseUrl === globalBaseUrl(provider) &&
|
||||||
|
!checkAdminAuth(req)
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
if (
|
||||||
|
baseUrl &&
|
||||||
|
!serverUrl &&
|
||||||
|
!allowPrivateUrls() &&
|
||||||
|
(await isPrivateUrl(baseUrl))
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -121,181 +113,133 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (provider !== "ollama" && !apiKey) {
|
} else if (provider === "vertexai") {
|
||||||
|
if (!vertexApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "Vertex AI API key is required for Express Mode",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The Test button checks the user's own provider. On the server's
|
||||||
|
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||||
|
if (
|
||||||
|
usesServerCredentials(provider, {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
vertexApiKey,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "API key is required" },
|
{ valid: false, error: "API key is required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let model: any
|
// On the deployment's own endpoints a Test runs a model as a chat
|
||||||
|
// does, so with the quota on it counts as a chat request (an
|
||||||
switch (provider) {
|
// admin's Test of the server's URL does not)
|
||||||
case "openai": {
|
const userId = getUserIdFromRequest(req)
|
||||||
const openai = createOpenAI({
|
if (
|
||||||
apiKey,
|
isQuotaEnabled() &&
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
!serverUrl &&
|
||||||
})
|
userId !== "anonymous" &&
|
||||||
model = openai.chat(modelId)
|
(await usesServerEndpoint(
|
||||||
break
|
provider,
|
||||||
}
|
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||||
|
apiKey,
|
||||||
case "anthropic": {
|
))
|
||||||
const anthropic = createAnthropic({
|
) {
|
||||||
apiKey,
|
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||||
})
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
model = anthropic(modelId)
|
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||||
break
|
})
|
||||||
}
|
if (!quotaCheck.allowed) {
|
||||||
|
|
||||||
case "google": {
|
|
||||||
const google = createGoogleGenerativeAI({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = google(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
|
||||||
const azure = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl,
|
|
||||||
})
|
|
||||||
model = azure.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "bedrock": {
|
|
||||||
const bedrock = createAmazonBedrock({
|
|
||||||
accessKeyId: awsAccessKeyId,
|
|
||||||
secretAccessKey: awsSecretAccessKey,
|
|
||||||
region: awsRegion,
|
|
||||||
})
|
|
||||||
model = bedrock(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "openrouter": {
|
|
||||||
const openrouter = createOpenRouter({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = openrouter(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
|
||||||
if (baseUrl || apiKey) {
|
|
||||||
const ds = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = ds(modelId)
|
|
||||||
} else {
|
|
||||||
model = deepseek(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "siliconflow": {
|
|
||||||
const sf = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
|
||||||
})
|
|
||||||
model = sf.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ollama": {
|
|
||||||
const ollama = createOllama({
|
|
||||||
baseURL: baseUrl || "http://localhost:11434",
|
|
||||||
})
|
|
||||||
model = ollama(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "gateway": {
|
|
||||||
const gw = createGateway({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = gw(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "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 uses DeepSeek-compatible API
|
|
||||||
const doubao = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
baseURL:
|
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
})
|
|
||||||
model = doubao(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: quotaCheck.error },
|
||||||
{ status: 400 },
|
{ status: 429 },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a minimal test request
|
// The same model the chat would use. A client base URL makes it
|
||||||
const startTime = Date.now()
|
// refuse redirects to internal hosts.
|
||||||
await generateText({
|
const { model } = getAIModel({
|
||||||
model,
|
provider,
|
||||||
prompt: "Say 'OK'",
|
modelId,
|
||||||
maxOutputTokens: 20,
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
trustedBaseUrl: serverUrl,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
// Temporary AWS credentials need it, as in the chat
|
||||||
|
awsSessionToken,
|
||||||
|
vertexApiKey,
|
||||||
|
// EdgeOne checks the Pages cookies and the access code
|
||||||
|
...(provider === "edgeone" && {
|
||||||
|
headers: {
|
||||||
|
cookie: req.headers.get("cookie") || "",
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Streaming, like the chat (some models only stream). Stop at the
|
||||||
|
// first tool call; a reasoning model that runs out of tokens first
|
||||||
|
// proves the connection but not tool support.
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
prompt: "Call the ping tool.",
|
||||||
|
tools: { ping: PING_TOOL },
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
maxRetries: 0,
|
||||||
|
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
let calledTool = false
|
||||||
|
let finishReason: string | undefined
|
||||||
|
for await (const part of result.fullStream) {
|
||||||
|
if (part.type === "error") throw part.error
|
||||||
|
// The timeout ends the stream with an abort part, not an error
|
||||||
|
if (part.type === "abort") {
|
||||||
|
const timeout = new Error(
|
||||||
|
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||||
|
)
|
||||||
|
timeout.name = "TimeoutError"
|
||||||
|
throw timeout
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
calledTool = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (part.type === "finish") finishReason = part.finishReason
|
||||||
|
}
|
||||||
const responseTime = Date.now() - startTime
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
responseTime,
|
responseTime,
|
||||||
|
...(!calledTool &&
|
||||||
|
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[validate-model] Error:", error)
|
console.error("[validate-model] Error:", error)
|
||||||
|
|
||||||
let errorMessage = "Validation failed"
|
const { code, message } = classifyLLMError(error)
|
||||||
if (error instanceof Error) {
|
|
||||||
// Extract meaningful error message
|
|
||||||
if (
|
|
||||||
error.message.includes("401") ||
|
|
||||||
error.message.includes("Unauthorized")
|
|
||||||
) {
|
|
||||||
errorMessage = "Invalid API key"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("404") ||
|
|
||||||
error.message.includes("not found")
|
|
||||||
) {
|
|
||||||
errorMessage = "Model not found"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("429") ||
|
|
||||||
error.message.includes("rate limit")
|
|
||||||
) {
|
|
||||||
errorMessage = "Rate limited - try again later"
|
|
||||||
} else if (error.message.includes("ECONNREFUSED")) {
|
|
||||||
errorMessage = "Cannot connect to server"
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message.slice(0, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: errorMessage },
|
{ valid: false, code, error: message },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ status: 200 }, // Return 200 so client can read error message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
|
import { checkAccessCode } from "@/lib/access-code"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const accessCodes =
|
if (checkAccessCode(req)) {
|
||||||
process.env.ACCESS_CODE_LIST?.split(",")
|
|
||||||
.map((code) => code.trim())
|
|
||||||
.filter(Boolean) || []
|
|
||||||
|
|
||||||
// If no access codes configured, verification always passes
|
|
||||||
if (accessCodes.length === 0) {
|
|
||||||
return Response.json({
|
|
||||||
valid: true,
|
|
||||||
message: "No access code required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessCodeHeader = req.headers.get("x-access-code")
|
|
||||||
|
|
||||||
if (!accessCodeHeader) {
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ valid: false, message: "Access code is required" },
|
{ valid: false, message: "Invalid or missing access code" },
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessCodes.includes(accessCodeHeader)) {
|
|
||||||
return Response.json(
|
|
||||||
{ valid: false, message: "Invalid access code" },
|
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -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);
|
||||||
|
|
||||||
/* Coral destructive */
|
/* Muted rose destructive */
|
||||||
--destructive: oklch(0.6 0.2 25);
|
--destructive: oklch(0.45 0.12 10);
|
||||||
|
|
||||||
/* 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.65 0.22 25);
|
--destructive: oklch(0.55 0.12 10);
|
||||||
|
|
||||||
--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,6 +244,19 @@
|
|||||||
.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 */
|
||||||
|
|||||||
+8
-2
@@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false
|
"ignoreUnknown": false,
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!public",
|
||||||
|
"!packages/mcp-server/src/preview",
|
||||||
|
"!lib/model-catalog.json"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Cloud } from "lucide-react"
|
import { Cloud } from "lucide-react"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -69,20 +70,62 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|||||||
export const ModelSelectorList = ({
|
export const ModelSelectorList = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorListProps) => (
|
}: ModelSelectorListProps) => {
|
||||||
<div className="relative">
|
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||||
<CommandList
|
const [showShadow, setShowShadow] = useState(false)
|
||||||
className={cn(
|
|
||||||
// Hide scrollbar on all platforms
|
useEffect(() => {
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
const listElement = listRef.current
|
||||||
className,
|
if (!listElement) return
|
||||||
)}
|
|
||||||
{...props}
|
const checkScroll = () => {
|
||||||
/>
|
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
// Show shadow if there is more content below
|
||||||
<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" />
|
// Using a small threshold to handle fractional pixel rendering
|
||||||
</div>
|
setShowShadow(
|
||||||
)
|
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkScroll()
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
listElement.addEventListener("scroll", checkScroll)
|
||||||
|
window.addEventListener("resize", checkScroll)
|
||||||
|
|
||||||
|
// Observe content changes (e.g. async loading of items)
|
||||||
|
const observer = new MutationObserver(checkScroll)
|
||||||
|
observer.observe(listElement, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("scroll", checkScroll)
|
||||||
|
window.removeEventListener("resize", checkScroll)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
// Hide scrollbar on all platforms
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||||
|
showShadow ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
@@ -134,6 +177,7 @@ 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`}
|
||||||
@@ -168,3 +212,27 @@ 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,9 +70,11 @@ 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()
|
||||||
|
|
||||||
@@ -120,49 +122,52 @@ export default function ExamplePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||||
{/* MCP Server Notice */}
|
{!minimal && (
|
||||||
<a
|
<>
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
{/* MCP Server Notice */}
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
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"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<div className="flex items-center gap-3">
|
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="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
>
|
||||||
<Terminal className="w-4 h-4 text-purple-500" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||||
<div className="min-w-0">
|
<Terminal className="w-4 h-4 text-purple-500" />
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
<div className="min-w-0">
|
||||||
{dict.examples.mcpServer}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
{dict.examples.mcpServer}
|
||||||
{dict.examples.preview}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.examples.mcpDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
</a>
|
||||||
{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">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
{!minimal && (
|
||||||
{dict.examples.quickExamples}
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||||
</p>
|
{dict.examples.quickExamples}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ExampleCard
|
<ExampleCard
|
||||||
|
|||||||
+431
-234
@@ -1,37 +1,62 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Link,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Square,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
forwardRef,
|
||||||
|
type SetStateAction,
|
||||||
|
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 { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||||
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
|
||||||
const MAX_FILES = 5
|
const MAX_FILES = 5
|
||||||
|
// Image formats every supported model provider accepts (SVG is read as text)
|
||||||
|
const SUPPORTED_IMAGE_TYPES = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
]
|
||||||
|
|
||||||
function isValidFileType(file: File): boolean {
|
function isValidFileType(file: File): boolean {
|
||||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
return (
|
||||||
|
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||||
|
isPdfFile(file) ||
|
||||||
|
isTextFile(file)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -135,18 +160,24 @@ 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
|
||||||
onClearChat: () => void
|
onStop?: () => 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>
|
||||||
|
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -154,92 +185,230 @@ interface ChatInputProps {
|
|||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
|
showUnvalidatedModels?: boolean
|
||||||
|
// Focus control props
|
||||||
|
shouldFocus?: boolean
|
||||||
|
onFocused?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||||
input,
|
function ChatInput(
|
||||||
status,
|
{
|
||||||
onSubmit,
|
input,
|
||||||
onChange,
|
status,
|
||||||
onClearChat,
|
onSubmit,
|
||||||
files = [],
|
onChange,
|
||||||
onFileChange = () => {},
|
onStop,
|
||||||
pdfData = new Map(),
|
files = [],
|
||||||
sessionId,
|
onFileChange = () => {},
|
||||||
error = null,
|
pdfData = new Map(),
|
||||||
models = [],
|
urlData,
|
||||||
selectedModelId,
|
onUrlChange,
|
||||||
onModelSelect = () => {},
|
sessionId,
|
||||||
showUnvalidatedModels = false,
|
error = null,
|
||||||
onConfigureModels = () => {},
|
models = [],
|
||||||
}: ChatInputProps) {
|
selectedModelId,
|
||||||
const dict = useDictionary()
|
onModelSelect = () => {},
|
||||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
onConfigureModels,
|
||||||
|
showUnvalidatedModels = false,
|
||||||
|
shouldFocus = false,
|
||||||
|
onFocused,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const {
|
||||||
|
chartXML,
|
||||||
|
diagramHistory,
|
||||||
|
saveDiagramToFile,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
} = useDiagram()
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
|
||||||
const isDisabled =
|
|
||||||
(status === "streaming" || status === "submitted") && !error
|
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
// Expose focus method via ref
|
||||||
const textarea = textareaRef.current
|
useImperativeHandle(ref, () => ({
|
||||||
if (textarea) {
|
focus: () => {
|
||||||
textarea.style.height = "auto"
|
textareaRef.current?.focus()
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Focus the textarea when shouldFocus becomes true
|
||||||
|
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFocus) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
onFocused?.()
|
||||||
|
}, 150)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [shouldFocus, onFocused])
|
||||||
|
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
|
const [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
|
||||||
|
// Block sending until attached files and URLs have their text, otherwise
|
||||||
|
// their content would be silently dropped
|
||||||
|
const isExtractingAttachments =
|
||||||
|
files.some((file) => pdfData.get(file)?.isExtracting) ||
|
||||||
|
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
|
||||||
|
|
||||||
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = "auto"
|
||||||
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
|
// Load send shortcut preference from localStorage and listen for changes
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||||
|
if (stored) setSendShortcut(stored)
|
||||||
|
|
||||||
|
const handleChange = (e: CustomEvent<string>) =>
|
||||||
|
setSendShortcut(e.detail)
|
||||||
|
window.addEventListener(
|
||||||
|
"sendShortcutChange",
|
||||||
|
handleChange as EventListener,
|
||||||
|
)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"sendShortcutChange",
|
||||||
|
handleChange as EventListener,
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
onChange(e)
|
||||||
|
adjustTextareaHeight()
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}, [input, adjustTextareaHeight])
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
onChange(e)
|
// Enter that confirms an IME candidate must not send the message
|
||||||
adjustTextareaHeight()
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const shouldSend =
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
sendShortcut === "enter"
|
||||||
e.preventDefault()
|
? e.key === "Enter" &&
|
||||||
const form = e.currentTarget.closest("form")
|
!e.shiftKey &&
|
||||||
if (form && input.trim() && !isDisabled) {
|
!e.ctrlKey &&
|
||||||
form.requestSubmit()
|
!e.metaKey
|
||||||
|
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||||
|
|
||||||
|
if (shouldSend) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget.closest("form")
|
||||||
|
if (
|
||||||
|
form &&
|
||||||
|
input.trim() &&
|
||||||
|
!isDisabled &&
|
||||||
|
!isExtractingAttachments
|
||||||
|
) {
|
||||||
|
form.requestSubmit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
if (isDisabled) return
|
if (isDisabled) return
|
||||||
|
|
||||||
const items = e.clipboardData.items
|
const items = e.clipboardData.items
|
||||||
const imageItems = Array.from(items).filter((item) =>
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
item.type.startsWith("image/"),
|
item.type.startsWith("image/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = (
|
const imageFiles = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
imageItems.map(async (item, index) => {
|
imageItems.map(async (item, index) => {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
return new File(
|
return new File(
|
||||||
[file],
|
[file],
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
{ type: file.type },
|
{ type: file.type },
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
).filter((f): f is File => f !== null)
|
||||||
|
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
imageFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
)
|
)
|
||||||
).filter((f): f is File => f !== null)
|
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(
|
const { validFiles, errors } = validateFiles(
|
||||||
imageFiles,
|
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
|
||||||
|
|
||||||
|
// Let validateFiles show a toast for unsupported types
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
Array.from(e.dataTransfer.files),
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -248,132 +417,100 @@ export function ChatInput({
|
|||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlExtract = async (url: string) => {
|
||||||
const newFiles = Array.from(e.target.files || [])
|
if (!onUrlChange) return
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
newFiles,
|
setIsExtractingUrl(true)
|
||||||
files.length,
|
|
||||||
dict,
|
// Use functional updates so a removal or send made while extracting
|
||||||
)
|
// is not overwritten when the request finishes
|
||||||
showValidationErrors(errors, dict)
|
try {
|
||||||
if (validFiles.length > 0) {
|
onUrlChange((prev) =>
|
||||||
onFileChange([...files, ...validFiles])
|
new Map(prev).set(url, {
|
||||||
|
url,
|
||||||
|
title: url,
|
||||||
|
content: "",
|
||||||
|
charCount: 0,
|
||||||
|
isExtracting: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
|
// Skip if the URL was removed while extracting
|
||||||
|
onUrlChange((prev) =>
|
||||||
|
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
||||||
|
)
|
||||||
|
|
||||||
|
setShowUrlDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
// Remove the URL from the data map on error
|
||||||
|
onUrlChange((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(url)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showErrorToast(
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to extract URL content"}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsExtractingUrl(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
return (
|
||||||
fileInputRef.current.value = ""
|
<form
|
||||||
}
|
id="chat-form"
|
||||||
}
|
onSubmit={onSubmit}
|
||||||
|
className={`w-full transition-all duration-200 ${
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
isDragging
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||||
if (fileInputRef.current) {
|
: ""
|
||||||
fileInputRef.current.value = ""
|
}`}
|
||||||
}
|
onDragOver={handleDragOver}
|
||||||
}
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
const triggerFileInput = () => {
|
>
|
||||||
fileInputRef.current?.click()
|
{/* File & URL previews */}
|
||||||
}
|
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||||
|
<div className="mb-3">
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
<FilePreviewList
|
||||||
e.preventDefault()
|
files={files}
|
||||||
e.stopPropagation()
|
onRemoveFile={handleRemoveFile}
|
||||||
setIsDragging(true)
|
pdfData={pdfData}
|
||||||
}
|
urlData={urlData}
|
||||||
|
onRemoveUrl={
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
onUrlChange
|
||||||
e.preventDefault()
|
? (url) =>
|
||||||
e.stopPropagation()
|
onUrlChange((prev) => {
|
||||||
setIsDragging(false)
|
const next = new Map(prev)
|
||||||
}
|
next.delete(url)
|
||||||
|
return next
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
})
|
||||||
e.preventDefault()
|
: undefined
|
||||||
e.stopPropagation()
|
}
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
|
||||||
isValidFileType(file),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
supportedFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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-between px-3 py-2 border-t border-border/50">
|
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowClearDialog(true)}
|
|
||||||
tooltipContent={dict.chat.clearConversation}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ResetWarningModal
|
|
||||||
open={showClearDialog}
|
|
||||||
onOpenChange={setShowClearDialog}
|
|
||||||
onClear={handleClear}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={dict.chat.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label="Chat input"
|
||||||
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 overflow-hidden justify-end">
|
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
@@ -394,7 +531,9 @@ export function ChatInput({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
disabled={isDisabled}
|
disabled={
|
||||||
|
isDisabled || !isRealDiagram(chartXML)
|
||||||
|
}
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -413,12 +552,38 @@ export function ChatInput({
|
|||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
{onUrlChange && (
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUrlDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.ExtractURL}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSaveAsTemplate(true)}
|
||||||
|
disabled={isDisabled || !input.trim()}
|
||||||
|
tooltipContent={dict.templates.saveAsTemplate}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||||
multiple
|
multiple
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
@@ -432,41 +597,73 @@ export function ChatInput({
|
|||||||
showUnvalidatedModels={showUnvalidatedModels}
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<Button
|
{(status === "streaming" || status === "submitted") &&
|
||||||
type="submit"
|
onStop ? (
|
||||||
disabled={isDisabled || !input.trim()}
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
onClick={onStop}
|
||||||
aria-label={
|
size="sm"
|
||||||
isDisabled ? dict.chat.sending : dict.chat.send
|
variant="destructive"
|
||||||
}
|
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
||||||
>
|
aria-label={dict.chat.stopGeneration}
|
||||||
{isDisabled ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Square className="h-4 w-4" />
|
||||||
) : (
|
</Button>
|
||||||
<>
|
) : (
|
||||||
<Send className="h-4 w-4 mr-1.5" />
|
<Button
|
||||||
{dict.chat.send}
|
type="submit"
|
||||||
</>
|
disabled={
|
||||||
)}
|
isDisabled ||
|
||||||
</Button>
|
isExtractingAttachments ||
|
||||||
|
!input.trim()
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
|
aria-label={dict.chat.send}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
|
{dict.chat.send}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<HistoryDialog
|
||||||
<HistoryDialog
|
showHistory={showHistory}
|
||||||
showHistory={showHistory}
|
onToggleHistory={setShowHistory}
|
||||||
onToggleHistory={setShowHistory}
|
/>
|
||||||
/>
|
<SaveDialog
|
||||||
<SaveDialog
|
open={showSaveDialog}
|
||||||
open={showSaveDialog}
|
onOpenChange={setShowSaveDialog}
|
||||||
onOpenChange={setShowSaveDialog}
|
onSave={(filename, format) =>
|
||||||
onSave={(filename, format) =>
|
saveDiagramToFile(
|
||||||
saveDiagramToFile(filename, format, sessionId)
|
filename,
|
||||||
}
|
format,
|
||||||
defaultFilename={`diagram-${new Date()
|
sessionId,
|
||||||
.toISOString()
|
dict.save.savedSuccessfully,
|
||||||
.slice(0, 10)}`}
|
)
|
||||||
/>
|
}
|
||||||
</form>
|
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||||
)
|
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
||||||
}
|
/>
|
||||||
|
{onUrlChange && (
|
||||||
|
<UrlInputDialog
|
||||||
|
open={showUrlDialog}
|
||||||
|
onOpenChange={setShowUrlDialog}
|
||||||
|
onSubmit={handleUrlExtract}
|
||||||
|
isExtracting={isExtractingUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={showSaveAsTemplate}
|
||||||
|
onOpenChange={setShowSaveAsTemplate}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowSaveAsTemplate(false)
|
||||||
|
// Let the template list in the lobby reload
|
||||||
|
window.dispatchEvent(new Event("templatesChanged"))
|
||||||
|
}}
|
||||||
|
initialPrompt={input.trim()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
+499
-528
File diff suppressed because it is too large
Load Diff
+956
-412
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
|||||||
|
"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) => {
|
||||||
|
// Ignore keys bubbling up from the delete button
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Plus } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type TemplateCreateInput,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
initialPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateCreateDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset form when dialog opens with the latest initialPrompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setPrompt(initialPrompt)
|
||||||
|
setPinned(false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, initialPrompt])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// React submit events bubble through the portal; keep them away from
|
||||||
|
// the chat form this dialog may be rendered in
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: TemplateCreateInput = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await createTemplate(input)
|
||||||
|
if (template) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create template:", err)
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{dict.templates.createTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.createDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt" className="text-foreground">
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-foreground">
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.templates.createButton}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Edit2 } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
template: Template | null
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
template,
|
||||||
|
onSuccess,
|
||||||
|
}: TemplateEditDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && template) {
|
||||||
|
setTitle(template.title || "")
|
||||||
|
setDescription(template.description || "")
|
||||||
|
setPrompt(template.prompt || "")
|
||||||
|
setPinned(template.pinned || false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, template])
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// React submit events bubble through the portal; keep them away from
|
||||||
|
// any form this dialog may be rendered in
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateTemplate(template.id, updates)
|
||||||
|
if (updated) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update template:", err)
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
{dict.templates.editTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.editDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-prompt"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-title"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="edit-pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.common.save}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"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()
|
||||||
|
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||||
|
window.addEventListener("templatesChanged", loadTemplates)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("templatesChanged", 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
|
||||||
|
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||||
|
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.exportSuccess.replace(
|
||||||
|
"{count}",
|
||||||
|
String(templates.length),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates from JSON file
|
||||||
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
dict.templates.importNoFile || "Please select a JSON file",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Validate import data
|
||||||
|
const validation = validateImportData(data)
|
||||||
|
if (!validation.valid) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
validation.error || "Invalid data",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates with dedup-append strategy
|
||||||
|
const result = await importTemplates(data.templates, templates)
|
||||||
|
|
||||||
|
// Reload template list
|
||||||
|
await loadTemplates()
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.importSuccess
|
||||||
|
.replace("{imported}", String(result.imported))
|
||||||
|
.replace("{skipped}", String(result.skipped)),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the empty state and the list, so import works in both
|
||||||
|
const importInput = (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const importMessageBox = importMessage && (
|
||||||
|
<div
|
||||||
|
className={`text-xs px-3 py-2 rounded-lg ${
|
||||||
|
importMessage.type === "success"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importMessage.text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty state: no templates at all
|
||||||
|
if (!loading && templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.templates.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.templates.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<FileText className="w-8 h-8 text-primary/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
|
{dict.templates.emptyTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||||
|
{dict.templates.emptyDescription}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{dict.templates.createFirst}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
{importInput}
|
||||||
|
{importMessageBox && (
|
||||||
|
<div className="mt-3">{importMessageBox}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template list
|
||||||
|
return (
|
||||||
|
<div className="py-2 px-2 animate-fade-in">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={dict.templates.searchPlaceholder}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.createButton}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={templates.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={dict.templates.exportTemplates}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.exportTemplates}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={dict.templates.importTemplates}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
{importInput}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import message */}
|
||||||
|
{importMessageBox}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading
|
||||||
|
? // Loading skeleton
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${String(i)}`}
|
||||||
|
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
|
<div className="h-3 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: filteredTemplates.length === 0
|
||||||
|
? // Search empty state
|
||||||
|
!loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||||
|
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{dict.templates.searchNoResults}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: filteredTemplates.map((template) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Ignore keys bubbling up from the action buttons
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{template.title}
|
||||||
|
</div>
|
||||||
|
{template.pinned && (
|
||||||
|
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions and stats */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
|
{template.runCount > 0
|
||||||
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||||
|
: dict.templates.neverUsed}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTogglePin(template)
|
||||||
|
}}
|
||||||
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
|
template.pinned
|
||||||
|
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
template.pinned
|
||||||
|
? dict.templates
|
||||||
|
.unpin || "Unpin"
|
||||||
|
: dict.templates.pin ||
|
||||||
|
"Pin"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={dict.common.edit}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDuplicate(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={
|
||||||
|
dict.templates.duplicate ||
|
||||||
|
"Duplicate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteClick(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
template={templateToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.deleteTitle ||
|
||||||
|
"Delete this template?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.deleteDescription ||
|
||||||
|
"This will permanently delete this template. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||||
|
>
|
||||||
|
{dict.common.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Confirm Send Dialog - when there's unsent input */}
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmSendDialogOpen}
|
||||||
|
onOpenChange={setConfirmSendDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.confirmSendTitle ||
|
||||||
|
"Replace current input?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.confirmSendDescription ||
|
||||||
|
"You have unsent content in the input. Sending this template will replace it."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelSend}>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
|
{dict.templates.confirmSendButton ||
|
||||||
|
"Send Template"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"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[] }) {
|
||||||
|
// Streamed or invalid input can hold anything: show only what React can
|
||||||
|
// render (an object in place of a string would crash the whole chat)
|
||||||
|
const shown = operations.filter(
|
||||||
|
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
||||||
|
)
|
||||||
|
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shown.map((op, index) => (
|
||||||
|
<div
|
||||||
|
key={`${op.operation}-${text(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: {text(op.cell_id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{text(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">
|
||||||
|
{text(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, errorText } = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incomplete XML means the output hit the length limit, unless the user
|
||||||
|
// stopped the generation themselves. Without an input the JSON was
|
||||||
|
// broken (the server repairs JSON cut short by the limit).
|
||||||
|
const isTruncated =
|
||||||
|
state === "output-error" &&
|
||||||
|
errorText !== "Stopped by user" &&
|
||||||
|
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||||
|
typeof input?.xml === "string" &&
|
||||||
|
!isMxCellXmlComplete(input.xml)
|
||||||
|
|
||||||
|
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" &&
|
||||||
|
(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>
|
||||||
|
)}
|
||||||
|
{/* AI SDK stores tool errors in errorText */}
|
||||||
|
{state === "output-error" && (errorText || output) && (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
||||||
|
: (errorText ?? 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
|
|
||||||
|
export type { DiagramOperation }
|
||||||
|
|
||||||
|
export interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: {
|
||||||
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
errorText?: string
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { wrapWithMxFile } from "@/lib/utils"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
const DEV_XML_PRESETS: Record<string, string> = {
|
const DEV_XML_PRESETS: Record<string, string> = {
|
||||||
@@ -142,6 +143,7 @@ export function DevXmlSimulator({
|
|||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onShowQuotaToast,
|
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)
|
||||||
@@ -178,7 +180,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: "[Dev] Simulating XML streaming",
|
text: dict.dev.simulatingMessage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -228,15 +230,15 @@ 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 = "Successfully displayed the diagram."
|
lastMsg.parts[0].output = dict.dev.successMessage
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
// Display the final diagram
|
// Display the final diagram
|
||||||
const fullXml = wrapWithMxFile(xml)
|
const prepared = prepareNewDiagram(xml)
|
||||||
onDisplayChart(fullXml)
|
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||||
|
|
||||||
setIsSimulating(false)
|
setIsSimulating(false)
|
||||||
}
|
}
|
||||||
@@ -245,12 +247,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">
|
||||||
Dev: XML Streaming Simulator
|
{dict.dev.title}
|
||||||
</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">
|
||||||
Preset:
|
{dict.dev.preset}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -262,7 +264,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select a preset...
|
{dict.dev.selectPreset}
|
||||||
</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}>
|
||||||
@@ -275,19 +277,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"
|
||||||
>
|
>
|
||||||
Clear
|
{dict.dev.clear}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder="Paste mxCell XML here or select a preset..."
|
placeholder={dict.dev.placeholder}
|
||||||
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">
|
||||||
Interval:
|
{dict.dev.interval}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -306,7 +308,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">
|
||||||
Chars:
|
{dict.dev.chars}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -330,8 +332,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
|
||||||
? "Streaming..."
|
? dict.dev.streaming
|
||||||
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -341,7 +343,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"
|
||||||
>
|
>
|
||||||
Stop
|
{dict.dev.stop}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onShowQuotaToast && (
|
{onShowQuotaToast && (
|
||||||
@@ -350,7 +352,7 @@ export function DevXmlSimulator({
|
|||||||
onClick={onShowQuotaToast}
|
onClick={onShowQuotaToast}
|
||||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||||
>
|
>
|
||||||
Test Quota Toast
|
{dict.dev.testQuotaToast}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FileCode, FileText, Loader2, X } from "lucide-react"
|
import { FileCode, FileText, Link, 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,12 +20,19 @@ 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)
|
||||||
@@ -77,7 +84,7 @@ export function FilePreviewList({
|
|||||||
}
|
}
|
||||||
}, [imageUrls, selectedImage])
|
}, [imageUrls, selectedImage])
|
||||||
|
|
||||||
if (files.length === 0) return null
|
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -152,6 +159,59 @@ 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">
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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} />
|
||||||
|
},
|
||||||
|
)
|
||||||
+1019
-985
File diff suppressed because it is too large
Load Diff
+261
-118
@@ -5,8 +5,10 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Monitor,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
@@ -19,38 +21,27 @@ 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 type { FlattenedModel } from "@/lib/types/model-config"
|
import {
|
||||||
|
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
|
showUnvalidatedModels?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map our provider names to models.dev logo names
|
|
||||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|
||||||
openai: "openai",
|
|
||||||
anthropic: "anthropic",
|
|
||||||
google: "google",
|
|
||||||
azure: "azure",
|
|
||||||
bedrock: "amazon-bedrock",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
siliconflow: "siliconflow",
|
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
|
||||||
gateway: "vercel",
|
|
||||||
doubao: "bytedance",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
@@ -60,7 +51,11 @@ function groupModelsByProvider(
|
|||||||
{ provider: string; models: FlattenedModel[] }
|
{ provider: string; models: FlattenedModel[] }
|
||||||
>()
|
>()
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const key = model.providerLabel
|
// For server models, strip "Server · " prefix for cleaner grouping
|
||||||
|
const key =
|
||||||
|
model.source === "server"
|
||||||
|
? model.providerLabel.replace(/^Server · /, "")
|
||||||
|
: model.providerLabel
|
||||||
const existing = groups.get(key)
|
const existing = groups.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
@@ -88,10 +83,26 @@ export function ModelSelector({
|
|||||||
}
|
}
|
||||||
return models.filter((m) => m.validated === true)
|
return models.filter((m) => m.validated === true)
|
||||||
}, [models, showUnvalidatedModels])
|
}, [models, showUnvalidatedModels])
|
||||||
const groupedModels = useMemo(
|
|
||||||
() => groupModelsByProvider(displayModels),
|
// Separate server and user models
|
||||||
|
const serverModels = useMemo(
|
||||||
|
() => displayModels.filter((m) => m.source === "server"),
|
||||||
[displayModels],
|
[displayModels],
|
||||||
)
|
)
|
||||||
|
const userModels = useMemo(
|
||||||
|
() => displayModels.filter((m) => m.source !== "server"),
|
||||||
|
[displayModels],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group each category separately
|
||||||
|
const groupedServerModels = useMemo(
|
||||||
|
() => groupModelsByProvider(serverModels),
|
||||||
|
[serverModels],
|
||||||
|
)
|
||||||
|
const groupedUserModels = useMemo(
|
||||||
|
() => groupModelsByProvider(userModels),
|
||||||
|
[userModels],
|
||||||
|
)
|
||||||
|
|
||||||
// Find selected model for display
|
// Find selected model for display
|
||||||
const selectedModel = useMemo(
|
const selectedModel = useMemo(
|
||||||
@@ -100,9 +111,7 @@ export function ModelSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value === "__configure__") {
|
if (value === "__server_default__") {
|
||||||
onConfigure()
|
|
||||||
} else if (value === "__server_default__") {
|
|
||||||
onSelect(undefined)
|
onSelect(undefined)
|
||||||
} else {
|
} else {
|
||||||
onSelect(value)
|
onSelect(value)
|
||||||
@@ -149,7 +158,7 @@ export function ModelSelector({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="inline-block">
|
<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
|
||||||
@@ -158,7 +167,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
|
"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",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -167,7 +176,7 @@ export function ModelSelector({
|
|||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
{/* show/hide visible label based on measured width */}
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<span className="text-xs truncate">
|
<span className="min-w-0 truncate text-xs">
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.modelId
|
? selectedModel.modelId
|
||||||
: dict.modelConfig.default}
|
: dict.modelConfig.default}
|
||||||
@@ -188,113 +197,247 @@ export function ModelSelector({
|
|||||||
<ModelSelectorInput
|
<ModelSelectorInput
|
||||||
placeholder={dict.modelConfig.searchModels}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
/>
|
/>
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<ModelSelectorEmpty>
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{displayModels.length === 0 && models.length > 0
|
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||||
? dict.modelConfig.noVerifiedModels
|
<ModelSelectorEmpty>
|
||||||
: dict.modelConfig.noModelsFound}
|
{displayModels.length === 0 &&
|
||||||
</ModelSelectorEmpty>
|
models.length > 0
|
||||||
|
? dict.modelConfig.noVerifiedModels
|
||||||
|
: dict.modelConfig.noModelsFound}
|
||||||
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
{/* Server Default Option */}
|
{/* Server Default Option - only show when no server models are configured */}
|
||||||
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
{serverModels.length === 0 && (
|
||||||
<ModelSelectorItem
|
<ModelSelectorGroup
|
||||||
value="__server_default__"
|
heading={dict.modelConfig.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>
|
|
||||||
|
|
||||||
{/* Configured Models by Provider */}
|
|
||||||
{Array.from(groupedModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={providerLabel}
|
|
||||||
heading={providerLabel}
|
|
||||||
>
|
|
||||||
{providerModels.map((model) => (
|
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
value="__server_default__"
|
||||||
value={model.modelId}
|
onSelect={handleSelect}
|
||||||
onSelect={() =>
|
className={cn(
|
||||||
handleSelect(model.id)
|
"cursor-pointer",
|
||||||
}
|
!selectedModelId && "bg-accent",
|
||||||
className="cursor-pointer"
|
)}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
selectedModelId === model.id
|
!selectedModelId
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ModelSelectorLogo
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
{model.modelId}
|
{dict.modelConfig.serverDefault}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
{model.validated !== true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
))}
|
</ModelSelectorGroup>
|
||||||
</ModelSelectorGroup>
|
)}
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Configure Option */}
|
{/* Server Models Section */}
|
||||||
<ModelSelectorSeparator />
|
{serverModels.length > 0 && (
|
||||||
<ModelSelectorGroup>
|
<>
|
||||||
<ModelSelectorItem
|
<ModelSelectorSectionHeader
|
||||||
value="__configure__"
|
icon={<Monitor />}
|
||||||
onSelect={handleSelect}
|
label={
|
||||||
className="cursor-pointer"
|
dict.modelConfig.serverModels
|
||||||
>
|
}
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
/>
|
||||||
<ModelSelectorName>
|
{Array.from(
|
||||||
{dict.modelConfig.configureModels}
|
groupedServerModels.entries(),
|
||||||
</ModelSelectorName>
|
).map(
|
||||||
</ModelSelectorItem>
|
([
|
||||||
</ModelSelectorGroup>
|
providerLabel,
|
||||||
{/* Info text */}
|
{
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
provider,
|
||||||
{showUnvalidatedModels
|
models: providerModels,
|
||||||
? dict.modelConfig.allModelsShown
|
},
|
||||||
: dict.modelConfig.onlyVerifiedShown}
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`server-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
// Unique value so same-named models highlight
|
||||||
|
// separately; keywords keep search by name
|
||||||
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.isDefault && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Models Section */}
|
||||||
|
{userModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<ModelSelectorSeparator />
|
||||||
|
)}
|
||||||
|
<ModelSelectorSectionHeader
|
||||||
|
icon={<User />}
|
||||||
|
label={dict.modelConfig.userModels}
|
||||||
|
/>
|
||||||
|
{Array.from(
|
||||||
|
groupedUserModels.entries(),
|
||||||
|
).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
models: providerModels,
|
||||||
|
},
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`user-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !==
|
||||||
|
true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModelSelectorList>
|
||||||
</div>
|
</div>
|
||||||
</ModelSelectorList>
|
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||||
|
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||||
|
{onConfigure && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__configure_models__"
|
||||||
|
onSelect={() => {
|
||||||
|
onConfigure()
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.configureModels}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||||
|
{showUnvalidatedModels
|
||||||
|
? dict.modelConfig.allModelsShown
|
||||||
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Key, Link2, Tag } from "lucide-react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import {
|
||||||
|
chatRequestUrl,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderName,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||||
|
// user dialog, write-only masked for the admin panel — supplied via
|
||||||
|
// renderSecret. That (and the optional test action) are the only genuine
|
||||||
|
// differences between the two screens; the field structure is shared here.
|
||||||
|
export type SecretField =
|
||||||
|
| "apiKey"
|
||||||
|
| "awsAccessKeyId"
|
||||||
|
| "awsSecretAccessKey"
|
||||||
|
| "vertexApiKey"
|
||||||
|
|
||||||
|
// AWS regions offered for Bedrock (shared by both screens)
|
||||||
|
export const AWS_REGIONS: Array<[string, string]> = [
|
||||||
|
["us-east-1", "N. Virginia"],
|
||||||
|
["us-east-2", "Ohio"],
|
||||||
|
["us-west-2", "Oregon"],
|
||||||
|
["eu-west-1", "Ireland"],
|
||||||
|
["eu-west-2", "London"],
|
||||||
|
["eu-west-3", "Paris"],
|
||||||
|
["eu-central-1", "Frankfurt"],
|
||||||
|
["ap-south-1", "Mumbai"],
|
||||||
|
["ap-northeast-1", "Tokyo"],
|
||||||
|
["ap-northeast-2", "Seoul"],
|
||||||
|
["ap-southeast-1", "Singapore"],
|
||||||
|
["ap-southeast-2", "Sydney"],
|
||||||
|
["sa-east-1", "São Paulo"],
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ProviderCredentialsFieldsProps {
|
||||||
|
provider: ProviderName
|
||||||
|
// Plain (non-secret) field values — secrets are owned by renderSecret
|
||||||
|
name?: string
|
||||||
|
baseUrl?: string
|
||||||
|
awsRegion?: string
|
||||||
|
disabled?: boolean
|
||||||
|
// Update a plain text field
|
||||||
|
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||||
|
// Render the control for a secret field. The caller may include trailing
|
||||||
|
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||||
|
// shared component only supplies the label above it.
|
||||||
|
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||||
|
// Extra content after the fields — used for the Bedrock test row and the
|
||||||
|
// EdgeOne test button, which aren't beside a credential input.
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name + per-provider credential inputs, shared by the user
|
||||||
|
// ModelConfigDialog and the admin Models panel.
|
||||||
|
export function ProviderCredentialsFields({
|
||||||
|
provider,
|
||||||
|
name,
|
||||||
|
baseUrl,
|
||||||
|
awsRegion,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
renderSecret,
|
||||||
|
footer,
|
||||||
|
}: ProviderCredentialsFieldsProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const info = PROVIDER_INFO[provider]
|
||||||
|
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||||
|
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||||
|
})
|
||||||
|
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||||
|
|
||||||
|
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||||
|
if (provider === "edgeone") {
|
||||||
|
return <div className="space-y-5">{footer}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Display Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="provider-name"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.displayName}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="provider-name"
|
||||||
|
value={name ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange("name", e.target.value)}
|
||||||
|
placeholder={info.label}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "bedrock" ? (
|
||||||
|
<>
|
||||||
|
{/* AWS Access Key ID */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-access-key-id"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsAccessKeyId}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "awsAccessKeyId",
|
||||||
|
id: "aws-access-key-id",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Secret Access Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-secret-access-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsSecretAccessKey}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "awsSecretAccessKey",
|
||||||
|
id: "aws-secret-access-key",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Region */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-region"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsRegion}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={awsRegion || ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => onChange("awsRegion", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="aws-region"
|
||||||
|
className="h-9 font-mono text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={dict.modelConfig.selectRegion}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
{AWS_REGIONS.map(([region, label]) => (
|
||||||
|
<SelectItem key={region} value={region}>
|
||||||
|
{region} ({label})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : provider === "vertexai" ? (
|
||||||
|
<>
|
||||||
|
{/* Vertex AI API Key (Express Mode) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="vertex-api-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.apiKey}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "vertexApiKey",
|
||||||
|
id: "vertex-api-key",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL (optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="vertex-base-url"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{baseUrlLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="vertex-base-url"
|
||||||
|
value={baseUrl ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("baseUrl", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={dict.modelConfig.customEndpoint}
|
||||||
|
className="h-9 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* API Key, with a link to where the provider issues keys */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="api-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.apiKey}
|
||||||
|
{provider === "ollama" &&
|
||||||
|
` ${dict.modelConfig.optional}`}
|
||||||
|
</Label>
|
||||||
|
{info.apiKeyUrl && (
|
||||||
|
<a
|
||||||
|
href={info.apiKeyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{dict.modelConfig.getApiKey}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="base-url"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{baseUrlLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="base-url"
|
||||||
|
value={baseUrl ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("baseUrl", e.target.value)
|
||||||
|
}
|
||||||
|
// Drop a pasted endpoint path such as /chat/completions
|
||||||
|
onBlur={(e) => {
|
||||||
|
const normalized = normalizeBaseUrl(
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
if (normalized !== e.target.value) {
|
||||||
|
onChange("baseUrl", normalized)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
info.defaultBaseUrl ||
|
||||||
|
dict.modelConfig.customEndpoint
|
||||||
|
}
|
||||||
|
className="h-9 rounded-xl font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{requestUrl && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||||
|
{formatMessage(dict.modelConfig.requestUrl, {
|
||||||
|
url: requestUrl,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "minimax" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.modelConfig.minimaxBaseUrlHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "mimo" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.modelConfig.mimoBaseUrlHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,9 +23,22 @@ export function QuotaLimitToast({
|
|||||||
}: 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()
|
||||||
@@ -71,19 +84,24 @@ 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>
|
<p>{quotaMessage}</p>
|
||||||
{isTokenLimit
|
{!isSelfHosted && (
|
||||||
? dict.quota.messageToken
|
<p
|
||||||
: dict.quota.messageApi}
|
dangerouslySetInnerHTML={{
|
||||||
</p>
|
__html: formatMessage(
|
||||||
|
dict.quota.doubaoSponsorship,
|
||||||
|
{
|
||||||
|
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p
|
<p
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
__html: tipHtml,
|
||||||
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
|
||||||
<p>{dict.quota.reset}</p>
|
<p>{dict.quota.reset}</p>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
@@ -101,24 +119,28 @@ export function QuotaLimitToast({
|
|||||||
{dict.quota.configModel}
|
{dict.quota.configModel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a
|
{!isSelfHosted && (
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
<>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
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"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<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 border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
{dict.quota.selfHost}
|
>
|
||||||
</a>
|
<FaGithub className="w-3.5 h-3.5" />
|
||||||
<a
|
{dict.quota.selfHost}
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
</a>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
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"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<Coffee 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 border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
{dict.quota.sponsor}
|
>
|
||||||
</a>
|
<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"
|
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,6 +74,11 @@ 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)
|
||||||
|
|||||||
+383
-56
@@ -1,8 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -21,9 +22,12 @@ 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"
|
||||||
|
|
||||||
// Reusable setting item component for consistent layout
|
// Reusable setting item component for consistent layout
|
||||||
function SettingItem({
|
function SettingItem({
|
||||||
@@ -54,22 +58,28 @@ 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
|
||||||
onCloseProtectionChange?: (enabled: boolean) => void
|
drawioUi: DrawioTheme
|
||||||
drawioUi: "min" | "sketch"
|
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||||
onToggleDrawioUi: () => void
|
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
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 {
|
||||||
@@ -82,30 +92,58 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onDrawioUiChange,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
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(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||||
if (getStoredAccessCodeRequired() !== null) return
|
// the access code input if the server requires it. This fixes the case
|
||||||
|
// 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) => {
|
||||||
@@ -121,10 +159,9 @@ function SettingsContent({
|
|||||||
setAccessCodeRequired(required)
|
setAccessCodeRequired(required)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Don't cache on error - allow retry on next mount
|
// Keep existing cached value on error
|
||||||
setAccessCodeRequired(false)
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [open])
|
||||||
|
|
||||||
// Detect current language from pathname
|
// Detect current language from pathname
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,13 +180,31 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedCloseProtection = localStorage.getItem(
|
const storedSendShortcut = localStorage.getItem(
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
STORAGE_KEYS.sendShortcut,
|
||||||
|
)
|
||||||
|
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])
|
||||||
|
|
||||||
@@ -157,6 +212,13 @@ function SettingsContent({
|
|||||||
// Save locale to localStorage for persistence across restarts
|
// Save locale to localStorage for persistence across restarts
|
||||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
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
|
||||||
@@ -208,8 +270,48 @@ 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">
|
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
{/* 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>
|
||||||
@@ -219,8 +321,29 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||||
<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">
|
||||||
@@ -314,42 +437,40 @@ 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
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Select
|
||||||
id="drawio-ui"
|
value={drawioUi}
|
||||||
variant="outline"
|
onValueChange={(v) =>
|
||||||
onClick={onToggleDrawioUi}
|
onDrawioUiChange(v as DrawioTheme)
|
||||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
}
|
||||||
>
|
>
|
||||||
{dict.settings.switchTo}{" "}
|
<SelectTrigger
|
||||||
{drawioUi === "min"
|
id="drawio-ui-select"
|
||||||
? dict.settings.sketch
|
aria-label={dict.settings.drawioStyle}
|
||||||
: dict.settings.minimal}
|
className="w-[120px] h-9 rounded-xl"
|
||||||
</Button>
|
>
|
||||||
</SettingItem>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
{/* Close Protection */}
|
<SelectContent>
|
||||||
<SettingItem
|
<SelectItem value="kennedy">
|
||||||
label={dict.settings.closeProtection}
|
{dict.settings.themeDefault}
|
||||||
description={dict.settings.closeProtectionDescription}
|
</SelectItem>
|
||||||
>
|
<SelectItem value="atlas">Atlas</SelectItem>
|
||||||
<Switch
|
<SelectItem value="dark">
|
||||||
id="close-protection"
|
{dict.settings.themeDark}
|
||||||
checked={closeProtection}
|
</SelectItem>
|
||||||
onCheckedChange={(checked) => {
|
<SelectItem value="min">
|
||||||
setCloseProtection(checked)
|
{dict.settings.themeMinimal}
|
||||||
localStorage.setItem(
|
</SelectItem>
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
<SelectItem value="sketch">
|
||||||
checked.toString(),
|
{dict.settings.themeSketch}
|
||||||
)
|
</SelectItem>
|
||||||
onCloseProtectionChange?.(checked)
|
<SelectItem value="simple">
|
||||||
}}
|
{dict.settings.themeSimple}
|
||||||
/>
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
@@ -370,6 +491,212 @@ function SettingsContent({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</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(
|
||||||
|
STORAGE_KEYS.sendShortcut,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("sendShortcutChange", {
|
||||||
|
detail: value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
@@ -395,13 +722,13 @@ function SettingsContent({
|
|||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">·</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
<a
|
<a
|
||||||
href="/about"
|
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Info className="h-3 w-3" />
|
<Info className="h-3 w-3" />
|
||||||
About
|
{dict.nav.about}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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,12 +77,13 @@ function CommandInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandList({
|
const CommandList = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
>(({ className, ...props }, ref) => {
|
||||||
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",
|
||||||
@@ -91,7 +92,8 @@ function CommandList({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+239
-178
@@ -1,30 +1,46 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
import { toast } from "sonner"
|
||||||
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 { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
import {
|
||||||
|
BLANK_MXFILE,
|
||||||
|
normalizeToMxfile,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
|
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||||
|
chartXMLRef: React.MutableRefObject<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
|
// Both return the export's tag (empty when draw.io is not there yet)
|
||||||
handleExportWithoutHistory: () => void
|
handleExport: () => string
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
handleExportWithoutHistory: () => string
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
// Pending exports by tag; a history or plain export's resolver gets the
|
||||||
handleDiagramExport: (data: any) => void
|
// first page's XML
|
||||||
|
exportResolversRef: React.MutableRefObject<
|
||||||
|
Record<string, (data: string, xml?: string) => void>
|
||||||
|
>
|
||||||
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
|
handleDiagramExport: (data: EventExport) => 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
|
||||||
saveDiagramToStorage: () => Promise<void>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
|
captureValidationPng: () => Promise<string | null>
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -34,6 +50,12 @@ interface DiagramContextType {
|
|||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
// Every export carries a tag in the request's `message` field. draw.io
|
||||||
|
// echoes the request back in the export event, so each result reaches its
|
||||||
|
// own caller. Tags end in a request number, so a late result never answers
|
||||||
|
// a newer request.
|
||||||
|
type ExportTag = "thumbnail" | "validation"
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("")
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||||
@@ -41,119 +63,114 @@ 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)
|
// Pending exports, keyed by their export tag
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
const exportResolversRef = useRef<
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
Record<string, (data: string, xml?: string) => void>
|
||||||
// Track if diagram has been restored from localStorage
|
>({})
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
// Pending history exports: the document each one was asked for
|
||||||
|
const historyXmlRef = useRef(new Map<string, string>())
|
||||||
|
const exportSeqRef = useRef(0)
|
||||||
|
// Track latest chartXML for restoration after remount
|
||||||
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore diagram XML when DrawIO becomes ready
|
// Update chartXML and its ref together, so callbacks that read the ref
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
// (export handler, autosave) see the new value right away
|
||||||
useEffect(() => {
|
const updateChartXML = (xml: string) => {
|
||||||
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
chartXMLRef.current = xml
|
||||||
if (!isDrawioReady) {
|
setChartXML(xml)
|
||||||
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)
|
|
||||||
const saveResolverRef = useRef<{
|
|
||||||
resolver: ((data: string) => void) | null
|
|
||||||
format: ExportFormat | null
|
|
||||||
}>({ resolver: null, format: null })
|
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (drawioRef.current) {
|
if (!drawioRef.current) return ""
|
||||||
// Mark that this export should be saved to history
|
// Save this export to history, with the document shown now:
|
||||||
expectHistoryExportRef.current = true
|
// chartXML can change before the result comes back
|
||||||
drawioRef.current.exportDiagram({
|
const tag = `history-${++exportSeqRef.current}`
|
||||||
format: "xmlsvg",
|
historyXmlRef.current.set(tag, chartXMLRef.current)
|
||||||
})
|
drawioRef.current.exportDiagram({
|
||||||
}
|
format: "xmlsvg",
|
||||||
|
message: tag,
|
||||||
|
})
|
||||||
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportWithoutHistory = () => {
|
const handleExportWithoutHistory = () => {
|
||||||
if (drawioRef.current) {
|
if (!drawioRef.current) return ""
|
||||||
// Export without saving to history (for edit_diagram fetching current state)
|
// Export without saving to history (for edit_diagram fetching current state)
|
||||||
drawioRef.current.exportDiagram({
|
const tag = `fetch-${++exportSeqRef.current}`
|
||||||
format: "xmlsvg",
|
drawioRef.current.exportDiagram({
|
||||||
})
|
format: "xmlsvg",
|
||||||
}
|
message: tag,
|
||||||
|
})
|
||||||
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current diagram to localStorage (used before theme/UI changes)
|
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||||
const saveDiagramToStorage = async (): Promise<void> => {
|
// event) and wait for that result. Resolves to null on timeout, which is
|
||||||
if (!drawioRef.current) return
|
// expected occasionally.
|
||||||
|
// (Reads refs only, so it keeps one identity)
|
||||||
|
const requestTaggedExport = useCallback(
|
||||||
|
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
const id = `${tag}-${++exportSeqRef.current}`
|
||||||
|
const finish = (value: string | null) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
delete exportResolversRef.current[id]
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
exportResolversRef.current[id] = finish
|
||||||
|
drawioRef.current?.exportDiagram({ format, message: id })
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||||
const currentXml = await Promise.race([
|
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||||
new Promise<string>((resolve) => {
|
// renders this provider again (latestSvg), which would otherwise start
|
||||||
resolverRef.current = resolve
|
// the next save
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||||
}),
|
if (!drawioRef.current) return null
|
||||||
new Promise<string>((_, reject) =>
|
// Don't export if diagram is empty
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 2000),
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Only save if diagram has meaningful content (not empty template)
|
// xmlsvg exports return an SVG data URL
|
||||||
if (currentXml && currentXml.length > 300) {
|
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
if (svgData?.startsWith("data:image/svg")) {
|
||||||
}
|
// Update latestSvg so it's available for future saves
|
||||||
} catch (error) {
|
setLatestSvg(svgData)
|
||||||
console.error("Failed to save diagram to storage:", error)
|
return svgData
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}, [requestTaggedExport])
|
||||||
|
|
||||||
|
// 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(chartXMLRef.current)) return null
|
||||||
|
|
||||||
|
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||||
|
// PNG data should be a base64 data URL
|
||||||
|
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (
|
||||||
@@ -162,9 +179,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
): string | null => {
|
): string | null => {
|
||||||
let xmlToLoad = chart
|
let xmlToLoad = chart
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal use)
|
// Validate XML structure before loading (unless skipped for internal
|
||||||
|
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||||
|
// tool handlers check model XML strictly before it gets here.
|
||||||
if (!skipValidation) {
|
if (!skipValidation) {
|
||||||
const validation = validateAndFixXml(chart)
|
const validation = validateAndFixXml(chart, { strict: false })
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[loadDiagram] Validation error:",
|
"[loadDiagram] Validation error:",
|
||||||
@@ -183,7 +202,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||||
setChartXML(xmlToLoad)
|
updateChartXML(xmlToLoad)
|
||||||
|
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
drawioRef.current.load({
|
drawioRef.current.load({
|
||||||
@@ -194,49 +213,68 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: any) => {
|
const handleDiagramExport = (data: EventExport) => {
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Thumbnail, validation PNG and file save exports go only to their
|
||||||
if (saveResolverRef.current.resolver) {
|
// own caller
|
||||||
const format = saveResolverRef.current.format
|
const tag = data.message?.message
|
||||||
saveResolverRef.current.resolver(data.data)
|
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
|
||||||
saveResolverRef.current = { resolver: null, format: null }
|
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
return
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
|
||||||
if (format === "png" || format === "svg") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't write chartXML here: exports don't change the diagram, and
|
||||||
|
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||||
|
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||||
|
// up to date with the full uncompressed multi-page document (#879).
|
||||||
const extractedXML = extractDiagramXML(data.data)
|
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
|
||||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||||
const MAX_HISTORY_SIZE = 20
|
const MAX_HISTORY_SIZE = 20
|
||||||
if (expectHistoryExportRef.current) {
|
const askedXml =
|
||||||
|
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
|
||||||
|
if (askedXml !== undefined) {
|
||||||
|
historyXmlRef.current.delete(tag as string)
|
||||||
|
// Store the full multi-page document (extractedXML is only the
|
||||||
|
// first page), so restoring a version keeps every page
|
||||||
|
const historyXml = askedXml || extractedXML
|
||||||
setDiagramHistory((prev) => {
|
setDiagramHistory((prev) => {
|
||||||
const newHistory = [
|
const newHistory = [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
svg: data.data,
|
svg: data.data,
|
||||||
xml: extractedXML,
|
xml: historyXml,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||||
})
|
})
|
||||||
expectHistoryExportRef.current = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolverRef.current) {
|
// The chat's own export (onFetchChart), not another one in flight
|
||||||
resolverRef.current(extractedXML)
|
const resolve =
|
||||||
resolverRef.current = null
|
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||||
|
if (resolve) {
|
||||||
|
delete exportResolversRef.current[tag as string]
|
||||||
|
resolve(extractedXML)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// react-drawio registers this callback once per iframe mount, so it must
|
||||||
|
// read refs: state captured in its closure would stay stale after a remount
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram but
|
||||||
|
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||||
|
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateChartXML(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 = BLANK_MXFILE
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
loadDiagram(emptyDiagram, true)
|
loadDiagram(emptyDiagram, true)
|
||||||
setLatestSvg("")
|
setLatestSvg("")
|
||||||
@@ -247,6 +285,7 @@ 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")
|
||||||
@@ -254,73 +293,91 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
const drawioFormat =
|
||||||
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Each save has its own tag, so two at once never swap results
|
||||||
saveResolverRef.current = {
|
const tag = `save-${++exportSeqRef.current}`
|
||||||
resolver: (exportData: string) => {
|
exportResolversRef.current[tag] = (
|
||||||
let fileContent: string | Blob
|
exportData: string,
|
||||||
let mimeType: string
|
fullDiagramXML?: string,
|
||||||
let extension: string
|
) => {
|
||||||
|
delete exportResolversRef.current[tag]
|
||||||
|
let fileContent: string | Blob
|
||||||
|
let mimeType: string
|
||||||
|
let extension: string
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Extract XML from SVG for .drawio format
|
// Prefer the complete document from the export event so all pages are saved.
|
||||||
const xml = extractDiagramXML(exportData)
|
const xml = fullDiagramXML?.trim()
|
||||||
let xmlContent = xml
|
? fullDiagramXML
|
||||||
if (!xml.includes("<mxfile")) {
|
: extractDiagramXML(exportData)
|
||||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
fileContent =
|
||||||
}
|
normalizeToMxfile(xml, {
|
||||||
fileContent = xmlContent
|
pageId: "page-1",
|
||||||
mimeType = "application/xml"
|
pageName: "Page-1",
|
||||||
extension = ".drawio"
|
}) ?? xml
|
||||||
|
mimeType = "application/xml"
|
||||||
|
extension = ".drawio"
|
||||||
|
} else if (format === "png") {
|
||||||
|
// PNG data comes as base64 data URL
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/png"
|
||||||
|
extension = ".png"
|
||||||
|
} else if (format === "xmlsvg") {
|
||||||
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".drawio.svg"
|
||||||
|
} else {
|
||||||
|
// SVG format (view-only)
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".svg"
|
||||||
|
}
|
||||||
|
|
||||||
// Save to localStorage when user manually saves
|
// Log save event to Langfuse (flags the trace)
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
logSaveToLangfuse(filename, format, sessionId)
|
||||||
} else if (format === "png") {
|
|
||||||
// PNG data comes as base64 data URL
|
|
||||||
fileContent = exportData
|
|
||||||
mimeType = "image/png"
|
|
||||||
extension = ".png"
|
|
||||||
} else {
|
|
||||||
// SVG format
|
|
||||||
fileContent = exportData
|
|
||||||
mimeType = "image/svg+xml"
|
|
||||||
extension = ".svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
// Handle download
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
let url: string
|
||||||
|
if (
|
||||||
|
typeof fileContent === "string" &&
|
||||||
|
fileContent.startsWith("data:")
|
||||||
|
) {
|
||||||
|
// Already a data URL (PNG)
|
||||||
|
url = fileContent
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([fileContent], { type: mimeType })
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle download
|
const a = document.createElement("a")
|
||||||
let url: string
|
a.href = url
|
||||||
if (
|
a.download = `${filename}${extension}`
|
||||||
typeof fileContent === "string" &&
|
document.body.appendChild(a)
|
||||||
fileContent.startsWith("data:")
|
a.click()
|
||||||
) {
|
document.body.removeChild(a)
|
||||||
// Already a data URL (PNG)
|
|
||||||
url = fileContent
|
|
||||||
} else {
|
|
||||||
const blob = new Blob([fileContent], { type: mimeType })
|
|
||||||
url = URL.createObjectURL(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = document.createElement("a")
|
// Show success toast after download is initiated
|
||||||
a.href = url
|
if (successMessage) {
|
||||||
a.download = `${filename}${extension}`
|
toast.success(successMessage, {
|
||||||
document.body.appendChild(a)
|
position: "bottom-left",
|
||||||
a.click()
|
duration: 2500,
|
||||||
document.body.removeChild(a)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
format,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// Export diagram - callback will be handled in handleDiagramExport
|
||||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
drawioRef.current.exportDiagram({
|
||||||
|
format: drawioFormat,
|
||||||
|
message: tag,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||||
@@ -344,17 +401,21 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
|
setDiagramHistory,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
exportResolversRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
saveDiagramToStorage,
|
getThumbnailSvg,
|
||||||
|
captureValidationPng,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
+6
-3
@@ -11,7 +11,10 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
volumes:
|
||||||
# For subdirectory deployment, uncomment and set your path:
|
# Persists admin panel settings (data/settings.json)
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
- ./data:/app/data
|
||||||
|
# environment:
|
||||||
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
# 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辅助可视化来创建、修改和增强图表。
|
|
||||||
|
|
||||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
- [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/)
|
|
||||||
|
|
||||||
> **使用自己的 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, doubao)
|
|
||||||
- 将 `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` 文件中所做的那样。
|
|
||||||
|
|
||||||
|
|
||||||
## 多提供商支持
|
|
||||||
|
|
||||||
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
|
||||||
- AWS Bedrock(默认)
|
|
||||||
- OpenAI
|
|
||||||
- Anthropic
|
|
||||||
- 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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
|
||||||
|
|
||||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
|
||||||
|
|
||||||
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
|
|
||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
|
||||||
|
|
||||||
## Star历史
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,225 +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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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, doubao, 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
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 常见问题解答 (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
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# 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服务器包含网页版的大部分画图功能:
|
||||||
|
|
||||||
|
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
||||||
|
- 截图工具,AI可以查看画好的图并自行修正
|
||||||
|
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
||||||
|
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
||||||
|
|
||||||
|
详情请参阅[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)
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 管理面板
|
||||||
|
|
||||||
|
无需手动编辑 `.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_*` 变量在构建时固化,无法在面板中修改。
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# 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=global.anthropic.claude-sonnet-5-5
|
||||||
|
```
|
||||||
|
|
||||||
|
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
|
||||||
|
|
||||||
|
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||||
|
|
||||||
|
### OpenRouter
|
||||||
|
|
||||||
|
```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 提供对众多模型的访问
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# 部署到 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**。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 使用 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) 了解配置选项。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 离线部署
|
||||||
|
|
||||||
|
通过自托管 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`),因为浏览器无法解析它们。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
# 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=global.anthropic.claude-sonnet-5-5
|
||||||
|
```
|
||||||
|
|
||||||
|
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
|
||||||
|
|
||||||
|
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||||
|
|
||||||
|
### OpenRouter
|
||||||
|
|
||||||
|
```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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# よくある質問 (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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||||
|
|
||||||
[English](../README.md) | [中文](./README_CN.md) | 日本語
|
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
[](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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
> 注:<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,16 +28,21 @@ 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へのデプロイ)
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
- [プロジェクト構造](#プロジェクト構造)
|
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||||
|
- [よくある質問](#よくある質問)
|
||||||
- [スター履歴](#スター履歴)
|
- [スター履歴](#スター履歴)
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
@@ -49,32 +54,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>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
<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" />
|
<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>GCPアーキテクチャ図</strong><br />
|
<strong>RAG技術ダイアグラム</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWSアーキテクチャ図</strong><br />
|
<strong>ReactとAWSによる認証</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azureアーキテクチャ図</strong><br />
|
<strong>オープンイノベーション</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</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>
|
||||||
@@ -91,9 +96,7 @@ 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を使用できます。
|
||||||
|
|
||||||
@@ -119,7 +122,14 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||||
|
|
||||||
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
||||||
|
|
||||||
|
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
||||||
|
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
||||||
|
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
||||||
|
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
||||||
|
|
||||||
|
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
|
|
||||||
@@ -127,39 +137,19 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
インストール不要!デモサイトで直接お試しください:
|
インストール不要!デモサイトで直接お試しください:
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||||
|
|
||||||
### Dockerで実行(推奨)
|
### デスクトップアプリケーション
|
||||||
|
|
||||||
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
|
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
|
||||||
|
|
||||||
まず、Dockerをインストールしていない場合はインストールしてください:[Dockerを入手](https://docs.docker.com/get-docker/)
|
対応プラットフォーム:Windows、macOS、Linux。
|
||||||
|
|
||||||
次に実行:
|
### Dockerで実行
|
||||||
|
|
||||||
```bash
|
[Docker ガイドを参照](./docker.md)
|
||||||
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) で設定オプションをご確認ください。
|
|
||||||
|
|
||||||
### インストール
|
### インストール
|
||||||
|
|
||||||
@@ -168,74 +158,82 @@ docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:l
|
|||||||
```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, doubao)
|
|
||||||
- `AI_MODEL`を使用する特定のモデルに設定
|
|
||||||
- プロバイダーに必要なAPIキーを追加
|
|
||||||
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
|
||||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
|
||||||
|
|
||||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
|
||||||
|
|
||||||
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||||
|
|
||||||
4. 開発サーバーを起動:
|
2. 開発サーバーを起動:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
|
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
||||||
|
|
||||||
## デプロイ
|
## デプロイ
|
||||||
|
|
||||||
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
|
### EdgeOne Pagesへのデプロイ
|
||||||
|
|
||||||
|
[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)
|
|
||||||
|
|
||||||
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
|
||||||
|
|
||||||
|
|
||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
- [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を推奨します。
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
||||||
|
|
||||||
|
**モデル要件**:このタスクは厳密なフォーマット制約(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)** — 有効化の方法、優先順位ルール、注意事項。
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
@@ -248,28 +246,10 @@ 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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
**デモサイトの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)をご検討ください!
|
||||||
|
|
||||||
@@ -277,6 +257,10 @@ public/ # サンプル画像を含む静的アセット
|
|||||||
|
|
||||||
- メール: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)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 管理パネル
|
||||||
|
|
||||||
|
`.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_*` 変数はビルド時に固定され、パネルでは変更できません。
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# 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=global.anthropic.claude-sonnet-5-5
|
||||||
|
```
|
||||||
|
|
||||||
|
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
|
||||||
|
|
||||||
|
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||||
|
|
||||||
|
### OpenRouter
|
||||||
|
|
||||||
|
```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 で多数のモデルへのアクセスを提供します
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# 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 あり** を選んでください。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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)を参照してください。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# オフラインデプロイ
|
||||||
|
|
||||||
|
`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 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
|
||||||
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
||||||
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
||||||
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
||||||
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
|
|
||||||
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
||||||
|
|
||||||
## Networking & Infrastructure
|
## Networking & Infrastructure
|
||||||
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
||||||
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
||||||
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
|
|
||||||
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
||||||
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
||||||
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
||||||
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
||||||
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
|
|
||||||
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
||||||
|
|
||||||
## General Diagrams
|
## General Diagrams
|
||||||
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
||||||
|
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
|
||||||
|
|
||||||
## Enterprise Software
|
## Enterprise Software
|
||||||
|
|
||||||
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
||||||
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
|
|
||||||
|
|
||||||
**Total: 33 libraries, 4,281 shapes**
|
**Total: 30 libraries, 4,184 shapes**
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API
|
||||||
|
*
|
||||||
|
* This endpoint provides an OpenAI-compatible API that can be used with
|
||||||
|
* AI SDK's createOpenAI({ baseURL: '/api/edgeai' })
|
||||||
|
*
|
||||||
|
* Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
// EdgeOne Pages global AI object
|
||||||
|
declare const AI: {
|
||||||
|
chatCompletions(options: {
|
||||||
|
model: string
|
||||||
|
messages: Array<{ role: string; content: string | null }>
|
||||||
|
stream?: boolean
|
||||||
|
max_tokens?: number
|
||||||
|
temperature?: number
|
||||||
|
tools?: any
|
||||||
|
tool_choice?: any
|
||||||
|
}): Promise<ReadableStream<Uint8Array> | any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageItemSchema = z
|
||||||
|
.object({
|
||||||
|
role: z.enum(["user", "assistant", "system", "tool", "function"]),
|
||||||
|
content: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const messageSchema = z
|
||||||
|
.object({
|
||||||
|
messages: z.array(messageItemSchema),
|
||||||
|
model: z.string().optional(),
|
||||||
|
stream: z.boolean().optional(),
|
||||||
|
tools: z.any().optional(),
|
||||||
|
tool_choice: z.any().optional(),
|
||||||
|
functions: z.any().optional(),
|
||||||
|
function_call: z.any().optional(),
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
top_p: z.number().optional(),
|
||||||
|
max_tokens: z.number().optional(),
|
||||||
|
presence_penalty: z.number().optional(),
|
||||||
|
frequency_penalty: z.number().optional(),
|
||||||
|
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
response_format: z.any().optional(),
|
||||||
|
seed: z.number().optional(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
n: z.number().int().optional(),
|
||||||
|
logit_bias: z.record(z.string(), z.number()).optional(),
|
||||||
|
parallel_tool_calls: z.boolean().optional(),
|
||||||
|
stream_options: z.any().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
// Model configuration
|
||||||
|
const ALLOWED_MODELS = [
|
||||||
|
"@tx/deepseek-ai/deepseek-v32",
|
||||||
|
"@tx/deepseek-ai/deepseek-r1-0528",
|
||||||
|
"@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
|
]
|
||||||
|
|
||||||
|
const MODEL_ALIASES: Record<string, string> = {
|
||||||
|
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
||||||
|
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
||||||
|
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create standardized JSON response
|
||||||
|
*/
|
||||||
|
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...extraHeaders,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
||||||
|
// function, so no CORS headers are sent: other sites' pages can't call it
|
||||||
|
// from a browser and spend the deployment's Edge AI quota.
|
||||||
|
// Same rule as lib/access-code.ts, but reading the edge function's env.
|
||||||
|
// No codes configured (or env unavailable) means no check.
|
||||||
|
function hasValidAccessCode(request: Request, env: any): boolean {
|
||||||
|
const accessCodes: string[] =
|
||||||
|
env?.ACCESS_CODE_LIST?.split(",")
|
||||||
|
.map((code: string) => code.trim())
|
||||||
|
.filter(Boolean) || []
|
||||||
|
if (accessCodes.length === 0) return true
|
||||||
|
const accessCode = request.headers.get("x-access-code")
|
||||||
|
return !!accessCode && accessCodes.includes(accessCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onRequest({ request, env }: any) {
|
||||||
|
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||||
|
// preflight, which fails without CORS headers. Only the type before any
|
||||||
|
// parameters counts: "text/plain; x=application/json" needs none.
|
||||||
|
const mediaType = (request.headers.get("content-type") ?? "")
|
||||||
|
.split(";")[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (request.method !== "POST" || mediaType !== "application/json") {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Expected a POST request with a JSON body",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidAccessCode(request, env)) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Invalid or missing access code",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers.delete("accept-encoding")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = await request.clone().json()
|
||||||
|
const parseResult = messageSchema.safeParse(json)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: parseResult.error.message,
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
||||||
|
parseResult.data
|
||||||
|
|
||||||
|
// Validate messages
|
||||||
|
const userMessages = messages.filter(
|
||||||
|
(message) => message.role === "user",
|
||||||
|
)
|
||||||
|
if (!userMessages.length) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "No user message found",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve model
|
||||||
|
const requestedModel = model || ALLOWED_MODELS[0]
|
||||||
|
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
||||||
|
|
||||||
|
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: `Invalid model: ${requestedModel}.`,
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isStream = !!stream
|
||||||
|
|
||||||
|
// Non-streaming: return mock response for validation
|
||||||
|
// AI.chatCompletions doesn't support non-streaming mode
|
||||||
|
if (!isStream) {
|
||||||
|
const mockResponse = {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: selectedModel,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "OK",
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 1,
|
||||||
|
total_tokens: 11,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return createResponse(mockResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build AI.chatCompletions options for streaming
|
||||||
|
const aiOptions: any = {
|
||||||
|
...extraParams,
|
||||||
|
model: selectedModel,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tools if provided
|
||||||
|
if (tools && tools.length > 0) {
|
||||||
|
aiOptions.tools = tools
|
||||||
|
}
|
||||||
|
if (tool_choice !== undefined) {
|
||||||
|
aiOptions.tool_choice = tool_choice
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiResponse = await AI.chatCompletions(aiOptions)
|
||||||
|
|
||||||
|
// Streaming response
|
||||||
|
return new Response(aiResponse, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache, no-store, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle EdgeOne specific errors
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(error.message)
|
||||||
|
if (message.code === 14020) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
|
||||||
|
type: "rate_limit_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return createResponse(
|
||||||
|
{ error: { message: error.message, type: "api_error" } },
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Not a JSON error message
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[EdgeOne] AI error:", error.message)
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: error.message || "AI service error",
|
||||||
|
type: "api_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[EdgeOne] Request error:", error.message)
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Request processing failed",
|
||||||
|
type: "server_error",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"nodeFunctionsConfig": {
|
||||||
|
"maxDuration": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,13 @@ directories:
|
|||||||
afterPack: ./scripts/afterPack.cjs
|
afterPack: ./scripts/afterPack.cjs
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- dist-electron/**/*
|
- from: dist-electron
|
||||||
- "!node_modules"
|
to: dist-electron
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
- from: .
|
||||||
|
filter:
|
||||||
|
- package.json
|
||||||
|
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
@@ -37,10 +42,11 @@ mac:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
hardenedRuntime: true
|
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
||||||
|
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
||||||
|
identity: null
|
||||||
|
hardenedRuntime: false
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
entitlements: resources/entitlements.mac.plist
|
|
||||||
entitlementsInherit: resources/entitlements.mac.plist
|
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents:
|
contents:
|
||||||
@@ -89,6 +95,10 @@ linux:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
|
- target: rpm
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
|
||||||
# Publish configuration (optional)
|
# Publish configuration (optional)
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
Vendored
+45
-5
@@ -25,6 +25,25 @@ interface ApplyPresetResult {
|
|||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Proxy configuration interface */
|
||||||
|
interface ProxyConfig {
|
||||||
|
httpProxy?: string
|
||||||
|
httpsProxy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of setting proxy */
|
||||||
|
interface SetProxyResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
devMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of setting user locale */
|
||||||
|
interface SetUserLocaleResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
@@ -41,10 +60,25 @@ declare global {
|
|||||||
maximize: () => void
|
maximize: () => void
|
||||||
/** Close the window */
|
/** Close the window */
|
||||||
close: () => void
|
close: () => void
|
||||||
/** Open file dialog and return file path */
|
/** Get proxy configuration */
|
||||||
openFile: () => Promise<string | null>
|
getProxy: () => Promise<ProxyConfig>
|
||||||
/** Save data to file via save dialog */
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||||
|
/** Get user's preferred locale */
|
||||||
|
getUserLocale: () => Promise<
|
||||||
|
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
||||||
|
>
|
||||||
|
/** Set user's preferred locale */
|
||||||
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
|
/**
|
||||||
|
* Call back after the server restarted on the same port (another
|
||||||
|
* preset); returns a function that stops the calls
|
||||||
|
*/
|
||||||
|
onServerRestarted?: (callback: () => void) => () => void
|
||||||
|
/** A chat was saved: open this port next launch */
|
||||||
|
chatSaved?: () => Promise<void>
|
||||||
|
/** The page loaded with this many chats */
|
||||||
|
chatsLoaded?: (count: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -71,4 +105,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ConfigPreset, ApplyPresetResult }
|
export type {
|
||||||
|
ApplyPresetResult,
|
||||||
|
ConfigPreset,
|
||||||
|
ProxyConfig,
|
||||||
|
SetProxyResult,
|
||||||
|
SetUserLocaleResult,
|
||||||
|
}
|
||||||
|
|||||||
+123
-72
@@ -12,11 +12,12 @@ import {
|
|||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
|
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { showSettingsWindow } from "./settings-window"
|
import { showSettingsWindow } from "./settings-window"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu
|
* Build and set the application menu with i18n support
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -25,18 +26,84 @@ export function buildAppMenu(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the menu (call this when presets change)
|
* Rebuild the menu (call this when presets change or language changes)
|
||||||
*/
|
*/
|
||||||
export function rebuildAppMenu(): void {
|
export function rebuildAppMenu(): void {
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Number of the latest preset switch
|
||||||
|
let lastSwitch = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template
|
* Apply a preset and restart the server so it takes effect.
|
||||||
|
* If the restart fails, go back to the previous preset and restart again,
|
||||||
|
* so the running server always matches the saved current preset.
|
||||||
|
* Throws an error describing the outcome on failure.
|
||||||
|
*/
|
||||||
|
export async function switchPreset(
|
||||||
|
id: string,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const previousPresetId = getCurrentPresetId()
|
||||||
|
const env = applyPresetToEnv(id)
|
||||||
|
if (!env) {
|
||||||
|
throw new Error("Preset not found")
|
||||||
|
}
|
||||||
|
const switchNumber = ++lastSwitch
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartNextServer()
|
||||||
|
return env
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restart server:", error)
|
||||||
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
// A newer switch started meanwhile (also of this same preset), or
|
||||||
|
// the preset was deleted: its own restart follows, and undoing
|
||||||
|
// would lose that choice
|
||||||
|
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert to previous preset on failure
|
||||||
|
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||||
|
setCurrentPreset(null)
|
||||||
|
}
|
||||||
|
// Rebuild menu to restore previous checkmark state
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartNextServer()
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error("Failed to restart server again:", retryError)
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the menu template with translations
|
||||||
*/
|
*/
|
||||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
// Get translations for preferred locale (saved preference or system default)
|
||||||
|
const locale = getPreferredLocale(app.getLocale())
|
||||||
|
const t = getMenuTranslations(locale)
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = []
|
const template: MenuItemConstructorOptions[] = []
|
||||||
|
|
||||||
// macOS app menu
|
// macOS app menu
|
||||||
@@ -44,10 +111,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Settings...",
|
label: t.settings,
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -55,26 +122,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "services" },
|
{ role: "services" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" }, // System-translated
|
||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" }, // System-translated
|
||||||
{ role: "unhide" },
|
{ role: "unhide" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{ role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "File",
|
label: t.file,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
...(isMac
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: t.settings,
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -83,76 +150,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
isMac ? { role: "close" } : { role: "quit" },
|
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Edit",
|
label: t.edit,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" },
|
{ role: "undo" }, // System-translated
|
||||||
{ role: "redo" },
|
{ role: "redo" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "cut" },
|
{ role: "cut" }, // System-translated
|
||||||
{ role: "copy" },
|
{ role: "copy" }, // System-translated
|
||||||
{ role: "paste" },
|
{ role: "paste" }, // System-translated
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
role: "pasteAndMatchStyle",
|
role: "pasteAndMatchStyle",
|
||||||
} as MenuItemConstructorOptions,
|
} as MenuItemConstructorOptions, // System-translated
|
||||||
{ role: "delete" } as MenuItemConstructorOptions,
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ role: "delete" } as MenuItemConstructorOptions,
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "View",
|
label: t.view,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" },
|
{ role: "reload" }, // System-translated
|
||||||
{ role: "forceReload" },
|
{ role: "forceReload" }, // System-translated
|
||||||
{ role: "toggleDevTools" },
|
{ role: "toggleDevTools" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "resetZoom" },
|
{ role: "resetZoom" }, // System-translated
|
||||||
{ role: "zoomIn" },
|
{ role: "zoomIn" }, // System-translated
|
||||||
{ role: "zoomOut" },
|
{ role: "zoomOut" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" },
|
{ role: "togglefullscreen" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
// Configuration menu with presets
|
||||||
template.push(buildConfigMenu())
|
template.push(buildConfigMenu(t))
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Window",
|
label: t.window,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "minimize" },
|
{ role: "minimize" }, // System-translated
|
||||||
{ role: "zoom" },
|
{ role: "zoom" }, // System-translated
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "front" } as MenuItemConstructorOptions,
|
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
||||||
]
|
]
|
||||||
: [{ role: "close" } as MenuItemConstructorOptions]),
|
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Help",
|
label: t.help,
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: "Documentation",
|
label: t.documentation,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||||
@@ -160,7 +227,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Report Issue",
|
label: t.reportIssue,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||||
@@ -176,7 +243,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
/**
|
/**
|
||||||
* Build the Configuration menu with presets
|
* Build the Configuration menu with presets
|
||||||
*/
|
*/
|
||||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
function buildConfigMenu(
|
||||||
|
t: ReturnType<typeof getMenuTranslations>,
|
||||||
|
): MenuItemConstructorOptions {
|
||||||
const presets = getAllPresets()
|
const presets = getAllPresets()
|
||||||
const currentPresetId = getCurrentPresetId()
|
const currentPresetId = getCurrentPresetId()
|
||||||
|
|
||||||
@@ -185,42 +254,24 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
|||||||
type: "radio",
|
type: "radio",
|
||||||
checked: preset.id === currentPresetId,
|
checked: preset.id === currentPresetId,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const previousPresetId = getCurrentPresetId()
|
try {
|
||||||
const env = applyPresetToEnv(preset.id)
|
await switchPreset(preset.id)
|
||||||
|
} catch (error) {
|
||||||
if (env) {
|
// Show error dialog to notify user
|
||||||
try {
|
dialog.showErrorBox(
|
||||||
await restartNextServer()
|
"Configuration Error",
|
||||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||||
} catch (error) {
|
)
|
||||||
console.error("Failed to restart server:", error)
|
|
||||||
|
|
||||||
// Revert to previous preset on failure
|
|
||||||
if (previousPresetId) {
|
|
||||||
applyPresetToEnv(previousPresetId)
|
|
||||||
} else {
|
|
||||||
setCurrentPreset(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild menu to restore previous checkmark state
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// Show error dialog to notify user
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Configuration Error",
|
|
||||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Configuration",
|
label: t.configuration,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(presetItems.length > 0
|
...(presetItems.length > 0
|
||||||
? [
|
? [
|
||||||
{ label: "Switch Preset", enabled: false },
|
{ label: t.switchPreset, enabled: false },
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
...presetItems,
|
...presetItems,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
@@ -229,8 +280,8 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
|||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
presetItems.length > 0
|
presetItems.length > 0
|
||||||
? "Manage Presets..."
|
? t.managePresets
|
||||||
: "Add Configuration Preset...",
|
: t.addConfigurationPreset,
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
showSettingsWindow(win || undefined)
|
showSettingsWindow(win || undefined)
|
||||||
|
|||||||
+182
-72
@@ -1,5 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { app, safeStorage } from "electron"
|
import { app, safeStorage } from "electron"
|
||||||
|
|
||||||
@@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false
|
|||||||
* Warns if encryption is not available (API key stored in plaintext)
|
* Warns if encryption is not available (API key stored in plaintext)
|
||||||
*/
|
*/
|
||||||
function encryptValue(value: string): string {
|
function encryptValue(value: string): string {
|
||||||
if (!value) {
|
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||||
|
// instead of wrapping it in a second layer of encryption
|
||||||
|
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ function encryptValue(value: string): string {
|
|||||||
/**
|
/**
|
||||||
* Decrypt a sensitive value using safeStorage
|
* Decrypt a sensitive value using safeStorage
|
||||||
* Returns the original value if it's not encrypted or decryption fails
|
* Returns the original value if it's not encrypted or decryption fails
|
||||||
|
* (so saving writes the stored ciphertext back unchanged)
|
||||||
*/
|
*/
|
||||||
function decryptValue(value: string): string {
|
function decryptValue(value: string): string {
|
||||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
@@ -137,6 +146,7 @@ interface ConfigPresetsFile {
|
|||||||
version: 1
|
version: 1
|
||||||
currentPresetId: string | null
|
currentPresetId: string | null
|
||||||
presets: ConfigPreset[]
|
presets: ConfigPreset[]
|
||||||
|
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
const CONFIG_FILE_NAME = "config-presets.json"
|
||||||
@@ -149,6 +159,10 @@ function getConfigFilePath(): string {
|
|||||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The presets file exists but the last read failed: a save now would
|
||||||
|
// replace the user's presets with the empty list that read returned
|
||||||
|
let presetsUnreadable = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load presets from the config file
|
* Load presets from the config file
|
||||||
* Decrypts sensitive fields automatically
|
* Decrypts sensitive fields automatically
|
||||||
@@ -157,15 +171,34 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
|
// Nothing left that a save could overwrite
|
||||||
|
presetsUnreadable = false
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = readFileSync(configPath, "utf-8")
|
||||||
|
presetsUnreadable = false
|
||||||
|
} catch (error) {
|
||||||
|
// Often only for now (on Windows an antivirus scanner can hold the
|
||||||
|
// file): keep the file, and refuse saves based on this empty list
|
||||||
|
console.error("Failed to read config presets:", error)
|
||||||
|
presetsUnreadable = true
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
currentPresetId: null,
|
||||||
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(configPath, "utf-8")
|
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
const data = JSON.parse(content) as ConfigPresetsFile
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
// Decrypt sensitive fields in each preset
|
||||||
@@ -177,10 +210,22 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config presets:", error)
|
console.error("Failed to load config presets:", error)
|
||||||
|
// Move the unreadable file aside so the next save can't overwrite
|
||||||
|
// the user's presets with an empty list
|
||||||
|
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
||||||
|
try {
|
||||||
|
renameSync(configPath, backupPath)
|
||||||
|
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||||
|
} catch (renameError) {
|
||||||
|
// Still there: refuse saves that would overwrite it
|
||||||
|
console.error("Failed to back up config presets:", renameError)
|
||||||
|
presetsUnreadable = true
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +235,11 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
* Encrypts sensitive fields automatically
|
* Encrypts sensitive fields automatically
|
||||||
*/
|
*/
|
||||||
export function savePresets(data: ConfigPresetsFile): void {
|
export function savePresets(data: ConfigPresetsFile): void {
|
||||||
|
if (presetsUnreadable) {
|
||||||
|
throw new Error(
|
||||||
|
"The presets file could not be read, so it was not overwritten. Please try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
const userDataPath = app.getPath("userData")
|
const userDataPath = app.getPath("userData")
|
||||||
|
|
||||||
@@ -208,7 +258,11 @@ export function savePresets(data: ConfigPresetsFile): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
|
// a truncated config file
|
||||||
|
const tempPath = `${configPath}.tmp`
|
||||||
|
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||||
|
renameSync(tempPath, configPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save config presets:", error)
|
console.error("Failed to save config presets:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -304,9 +358,10 @@ export function deletePreset(id: string): boolean {
|
|||||||
|
|
||||||
data.presets.splice(index, 1)
|
data.presets.splice(index, 1)
|
||||||
|
|
||||||
// Clear current preset if it was deleted
|
// Clear current preset (and its env vars) if it was deleted
|
||||||
if (data.currentPresetId === id) {
|
if (data.currentPresetId === id) {
|
||||||
data.currentPresetId = null
|
data.currentPresetId = null
|
||||||
|
setPresetEnv(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
@@ -319,13 +374,15 @@ export function deletePreset(id: string): boolean {
|
|||||||
export function setCurrentPreset(id: string | null): boolean {
|
export function setCurrentPreset(id: string | null): boolean {
|
||||||
const data = loadPresets()
|
const data = loadPresets()
|
||||||
|
|
||||||
|
let preset: ConfigPreset | null = null
|
||||||
if (id !== null) {
|
if (id !== null) {
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
preset = data.presets.find((p) => p.id === id) || null
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPresetEnv(preset)
|
||||||
data.currentPresetId = id
|
data.currentPresetId = id
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
return true
|
return true
|
||||||
@@ -351,85 +408,34 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
apiKey: "SILICONFLOW_API_KEY",
|
apiKey: "SILICONFLOW_API_KEY",
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
baseUrl: "SILICONFLOW_BASE_URL",
|
||||||
},
|
},
|
||||||
|
modelscope: {
|
||||||
|
apiKey: "MODELSCOPE_API_KEY",
|
||||||
|
baseUrl: "MODELSCOPE_BASE_URL",
|
||||||
|
},
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock and ollama don't use API keys in the same way
|
// bedrock doesn't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply preset environment variables to the current process
|
* Map a preset's config to environment variables
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
|
||||||
|
|
||||||
if (!preset) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEnv: Record<string, string> = {}
|
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
|
||||||
if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
process.env[providerApiKey] = value
|
|
||||||
appliedEnv[providerApiKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Map generic AI_BASE_URL to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_BASE_URL" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
||||||
if (providerBaseUrl) {
|
|
||||||
process.env[providerBaseUrl] = value
|
|
||||||
appliedEnv[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
process.env[key] = value
|
|
||||||
appliedEnv[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set as current preset
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return appliedEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get environment variables from current preset
|
|
||||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||||
*/
|
*/
|
||||||
export function getCurrentPresetEnv(): Record<string, string> {
|
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||||
const preset = getCurrentPreset()
|
|
||||||
if (!preset) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {}
|
const env: Record<string, string> = {}
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
for (const [key, value] of Object.entries(preset.config)) {
|
||||||
if (value !== undefined && value !== "") {
|
if (value !== undefined && value !== "") {
|
||||||
|
// A key that could not be decrypted is useless to the server
|
||||||
|
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
console.warn(
|
||||||
|
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
// Map generic AI_API_KEY to provider-specific key
|
||||||
if (
|
else if (
|
||||||
key === "AI_API_KEY" &&
|
key === "AI_API_KEY" &&
|
||||||
provider &&
|
provider &&
|
||||||
PROVIDER_ENV_MAP[provider]
|
PROVIDER_ENV_MAP[provider]
|
||||||
@@ -458,3 +464,107 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values that env vars had before a preset first set them
|
||||||
|
* (from the system or .env files), and the keys the active preset set
|
||||||
|
*/
|
||||||
|
const originalEnv: Record<string, string | undefined> = {}
|
||||||
|
let presetEnvKeys: string[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the env vars of the previous preset with those of the given preset
|
||||||
|
* (null leaves no preset applied). Restoring first means switching presets
|
||||||
|
* never leaves the previous preset's base URL, model or key behind.
|
||||||
|
*/
|
||||||
|
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||||
|
for (const key of presetEnvKeys) {
|
||||||
|
if (originalEnv[key] === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = originalEnv[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = preset ? presetToEnv(preset) : {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
originalEnv[key] = process.env[key]
|
||||||
|
}
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
presetEnvKeys = Object.keys(env)
|
||||||
|
|
||||||
|
writeDevPresetEnv(env)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||||
|
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||||
|
* this file changes. The dev server can't decrypt the config file itself.
|
||||||
|
*/
|
||||||
|
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||||
|
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||||
|
encoding: "utf-8",
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to write dev preset env:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preset environment variables to the current process
|
||||||
|
* Returns the environment variables that were applied
|
||||||
|
*/
|
||||||
|
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||||
|
const data = loadPresets()
|
||||||
|
const preset = data.presets.find((p) => p.id === id)
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedEnv = setPresetEnv(preset)
|
||||||
|
|
||||||
|
// Set as current preset
|
||||||
|
data.currentPresetId = id
|
||||||
|
savePresets(data)
|
||||||
|
|
||||||
|
return appliedEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the saved current preset's environment variables (used at startup)
|
||||||
|
*/
|
||||||
|
export function applyCurrentPresetToEnv(): void {
|
||||||
|
setPresetEnv(getCurrentPreset())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's preferred locale from config
|
||||||
|
* Returns undefined if not set
|
||||||
|
*/
|
||||||
|
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
||||||
|
const data = loadPresets()
|
||||||
|
return data.userLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user's preferred locale in config
|
||||||
|
*/
|
||||||
|
export function setUserLocale(
|
||||||
|
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
||||||
|
): void {
|
||||||
|
const data = loadPresets()
|
||||||
|
data.userLocale = locale === null ? undefined : locale
|
||||||
|
savePresets(data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ export function loadEnvFile(): void {
|
|||||||
console.log("No .env file found, using system environment variables")
|
console.log("No .env file found, using system environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the quote that closes a value starting with a quote, or -1. A
|
||||||
|
* backslash before the quote character escapes it, as in dotenv; the
|
||||||
|
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||||
|
* comment or nothing after it still closes the value when no other quote
|
||||||
|
* does ("C:\dir\" keeps its trailing backslash).
|
||||||
|
*/
|
||||||
|
function findClosingQuote(value: string): number {
|
||||||
|
const quote = value[0]
|
||||||
|
let lastEscaped = -1
|
||||||
|
for (let i = 1; i < value.length; i++) {
|
||||||
|
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||||
|
i++
|
||||||
|
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||||
|
} else if (value[i] === quote) return i
|
||||||
|
}
|
||||||
|
return lastEscaped
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and load environment variables from a file
|
* Parse and load environment variables from a file
|
||||||
*/
|
*/
|
||||||
@@ -48,12 +67,28 @@ function loadEnvFromFile(filePath: string): void {
|
|||||||
const key = trimmed.slice(0, equalIndex).trim()
|
const key = trimmed.slice(0, equalIndex).trim()
|
||||||
let value = trimmed.slice(equalIndex + 1).trim()
|
let value = trimmed.slice(equalIndex + 1).trim()
|
||||||
|
|
||||||
// Remove surrounding quotes
|
const quote = value[0]
|
||||||
|
const closingQuote =
|
||||||
|
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||||
if (
|
if (
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
closingQuote > 0 &&
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||||
) {
|
) {
|
||||||
value = value.slice(1, -1)
|
// Quoted value, then nothing or a comment: keep what is
|
||||||
|
// inside the quotes, as dotenv reads it
|
||||||
|
value = value.slice(1, closingQuote)
|
||||||
|
} else {
|
||||||
|
// Unquoted value: drop an inline comment ("value # comment").
|
||||||
|
// A value quoted from start to end with quotes inside (JSON
|
||||||
|
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||||
|
value = value.replace(/\s+#.*$/, "")
|
||||||
|
if (
|
||||||
|
closingQuote > 0 &&
|
||||||
|
value.length > 1 &&
|
||||||
|
value.endsWith(quote)
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't override existing environment variables
|
// Don't override existing environment variables
|
||||||
|
|||||||
+53
-18
@@ -1,11 +1,17 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { getCurrentPresetEnv } from "./config-manager"
|
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
|
import { applyProxyToEnv } from "./proxy-manager"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import {
|
||||||
|
createWindow,
|
||||||
|
getAppUrl,
|
||||||
|
getMainWindow,
|
||||||
|
isAppUrl,
|
||||||
|
} from "./window-manager"
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
@@ -24,16 +30,17 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
loadEnvFile()
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
// Apply proxy settings from saved config
|
||||||
const presetEnv = getCurrentPresetEnv()
|
applyProxyToEnv()
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
const isDev = !app.isPackaged
|
||||||
let serverUrl: string | null = null
|
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
// Apply saved preset environment variables (overrides .env).
|
||||||
|
// Must run after ready: on Windows and Linux safeStorage can't
|
||||||
|
// decrypt the API key before that.
|
||||||
|
applyCurrentPresetToEnv()
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
registerSettingsWindowHandlers()
|
||||||
@@ -42,6 +49,7 @@ if (!gotTheLock) {
|
|||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let serverUrl: string
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Development: use the dev server URL
|
// Development: use the dev server URL
|
||||||
serverUrl =
|
serverUrl =
|
||||||
@@ -65,8 +73,9 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
if (serverUrl) {
|
const appUrl = getAppUrl()
|
||||||
createWindow(serverUrl)
|
if (appUrl) {
|
||||||
|
createWindow(appUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -83,23 +92,49 @@ if (!gotTheLock) {
|
|||||||
stopNextServer()
|
stopNextServer()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pages allowed inside app windows: the app server and draw.io
|
||||||
|
const isInAppUrl = (url: string): boolean => {
|
||||||
|
if (isAppUrl(url)) return true
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(url)
|
||||||
|
return ["diagrams.net", "draw.io"].some(
|
||||||
|
(domain) =>
|
||||||
|
hostname === domain || hostname.endsWith(`.${domain}`),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWebUrl = (url: string): boolean =>
|
||||||
|
url.startsWith("http://") || url.startsWith("https://")
|
||||||
|
|
||||||
// Open external links in default browser
|
// Open external links in default browser
|
||||||
app.on("web-contents-created", (_, contents) => {
|
app.on("web-contents-created", (_, contents) => {
|
||||||
contents.setWindowOpenHandler(({ url }) => {
|
contents.setWindowOpenHandler(({ url }) => {
|
||||||
// Allow diagrams.net iframe
|
if (isInAppUrl(url)) {
|
||||||
if (
|
|
||||||
url.includes("diagrams.net") ||
|
|
||||||
url.includes("draw.io") ||
|
|
||||||
url.startsWith("http://localhost")
|
|
||||||
) {
|
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
// Open other links in external browser
|
// Open other links in external browser
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (isWebUrl(url)) {
|
||||||
shell.openExternal(url)
|
shell.openExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clicking a plain link would otherwise replace the app page with
|
||||||
|
// an external site that keeps the preload API. Only the page
|
||||||
|
// itself may navigate there; draw.io stays in its frame (this event
|
||||||
|
// is for the main frame only)
|
||||||
|
contents.on("will-navigate", (event) => {
|
||||||
|
if (isAppUrl(event.url)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
if (isWebUrl(event.url)) {
|
||||||
|
shell.openExternal(event.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-91
@@ -1,16 +1,26 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||||
|
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
createPreset,
|
createPreset,
|
||||||
deletePreset,
|
deletePreset,
|
||||||
getAllPresets,
|
getAllPresets,
|
||||||
getCurrentPreset,
|
getCurrentPreset,
|
||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
|
getUserLocale,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
|
setUserLocale,
|
||||||
updatePreset,
|
updatePreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
|
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||||
|
import {
|
||||||
|
applyProxyToEnv,
|
||||||
|
getProxyConfig,
|
||||||
|
type ProxyConfig,
|
||||||
|
saveProxyConfig,
|
||||||
|
} from "./proxy-manager"
|
||||||
|
import { isAppUrl } from "./window-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -39,16 +49,44 @@ function sanitizePresetConfig(
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an IPC handler that only answers the app's own pages
|
||||||
|
* (the main window on the app server, or the local settings page).
|
||||||
|
* A main window that somehow ends up on an external site still gets the
|
||||||
|
* preload API, so its calls must be rejected here.
|
||||||
|
*/
|
||||||
|
function handle<Args extends unknown[]>(
|
||||||
|
channel: string,
|
||||||
|
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(channel, (event, ...args) => {
|
||||||
|
const url = event.senderFrame?.url
|
||||||
|
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||||
|
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||||
|
}
|
||||||
|
return listener(event, ...(args as Args))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
*/
|
*/
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ==================== App Info ====================
|
// ==================== App Info ====================
|
||||||
|
|
||||||
ipcMain.handle("get-version", () => {
|
handle("get-version", () => {
|
||||||
return app.getVersion()
|
return app.getVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== Where the chats are ====================
|
||||||
|
|
||||||
|
// The page saved a chat, or loaded without any: decides which port
|
||||||
|
// (and so which origin's chats) the next launch opens
|
||||||
|
handle("chat-saved", () => rememberChatPort())
|
||||||
|
handle("chats-loaded", (_event, count: unknown) => {
|
||||||
|
if (count === 0) noteNoChats()
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== Window Controls ====================
|
// ==================== Window Controls ====================
|
||||||
|
|
||||||
ipcMain.on("window-minimize", (event) => {
|
ipcMain.on("window-minimize", (event) => {
|
||||||
@@ -70,84 +108,30 @@ export function registerIpcHandlers(): void {
|
|||||||
win?.close()
|
win?.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== File Dialogs ====================
|
|
||||||
|
|
||||||
ipcMain.handle("dialog-open-file", async (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (!win) return null
|
|
||||||
|
|
||||||
const result = await dialog.showOpenDialog(win, {
|
|
||||||
properties: ["openFile"],
|
|
||||||
filters: [
|
|
||||||
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
|
||||||
{ name: "All Files", extensions: ["*"] },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file content
|
|
||||||
const fs = await import("node:fs/promises")
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
|
||||||
return content
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to read file:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (!win) return false
|
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog(win, {
|
|
||||||
filters: [
|
|
||||||
{ name: "Draw.io Files", extensions: ["drawio"] },
|
|
||||||
{ name: "XML Files", extensions: ["xml"] },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fs = await import("node:fs/promises")
|
|
||||||
try {
|
|
||||||
await fs.writeFile(result.filePath, data, "utf-8")
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save file:", error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
// ==================== Config Presets ====================
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-all", () => {
|
handle("config-presets:get-all", () => {
|
||||||
return getAllPresets()
|
return getAllPresets()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current", () => {
|
handle("config-presets:get-current", () => {
|
||||||
return getCurrentPreset()
|
return getCurrentPreset()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current-id", () => {
|
handle("config-presets:get-current-id", () => {
|
||||||
return getCurrentPresetId()
|
return getCurrentPresetId()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
handle(
|
||||||
"config-presets:save",
|
"config-presets:save",
|
||||||
(
|
async (
|
||||||
_event,
|
_event,
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||||
id?: string
|
id?: string
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// Validate preset name
|
// Validate preset name
|
||||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
||||||
throw new Error("Invalid preset name")
|
throw new Error("Invalid preset name")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,42 +140,48 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
if (preset.id) {
|
if (preset.id) {
|
||||||
// Update existing preset
|
// Update existing preset
|
||||||
return updatePreset(preset.id, {
|
const updated = updatePreset(preset.id, {
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
|
// Re-apply the active preset so the edit takes effect
|
||||||
|
if (updated && updated.id === getCurrentPresetId()) {
|
||||||
|
await switchPreset(updated.id)
|
||||||
|
} else {
|
||||||
|
rebuildAppMenu()
|
||||||
|
}
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
// Create new preset
|
// Create new preset
|
||||||
return createPreset({
|
const created = createPreset({
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
|
rebuildAppMenu()
|
||||||
|
return created
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
handle("config-presets:delete", async (_event, id: string) => {
|
||||||
return deletePreset(id)
|
const wasCurrent = id === getCurrentPresetId()
|
||||||
|
// Deleting the active preset also clears its env vars
|
||||||
|
const deleted = deletePreset(id)
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
// Restart so the server stops using the deleted preset
|
||||||
|
if (deleted && wasCurrent && app.isPackaged) {
|
||||||
|
await restartNextServer()
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
|
handle("config-presets:apply", async (_event, id: string) => {
|
||||||
const env = applyPresetToEnv(id)
|
|
||||||
if (!env) {
|
|
||||||
return { success: false, error: "Preset not found" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development mode, the config file change will trigger
|
|
||||||
// the file watcher in electron-dev.mjs to restart Next.js
|
|
||||||
// We just need to save the preset (already done in applyPresetToEnv)
|
|
||||||
return { success: true, env, devMode: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production mode: restart the Next.js server to apply new environment variables
|
|
||||||
try {
|
try {
|
||||||
await restartNextServer()
|
const env = await switchPreset(id)
|
||||||
return { success: true, env }
|
// In development mode, electron-dev.mjs restarts Next.js
|
||||||
|
return app.isPackaged
|
||||||
|
? { success: true, env }
|
||||||
|
: { success: true, env, devMode: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -203,10 +193,83 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||||
"config-presets:set-current",
|
return setCurrentPreset(id)
|
||||||
(_event, id: string | null) => {
|
})
|
||||||
return setCurrentPreset(id)
|
|
||||||
},
|
// ==================== Proxy Settings ====================
|
||||||
)
|
|
||||||
|
handle("get-proxy", () => {
|
||||||
|
return getProxyConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||||
|
const isOptionalString = (value: unknown) =>
|
||||||
|
value === undefined || typeof value === "string"
|
||||||
|
if (
|
||||||
|
typeof config !== "object" ||
|
||||||
|
config === null ||
|
||||||
|
!isOptionalString(config.httpProxy) ||
|
||||||
|
!isOptionalString(config.httpsProxy)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid proxy settings" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save config to file
|
||||||
|
saveProxyConfig({
|
||||||
|
httpProxy: config.httpProxy,
|
||||||
|
httpsProxy: config.httpsProxy,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply to current process environment
|
||||||
|
applyProxyToEnv()
|
||||||
|
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
// In development, env vars are already applied
|
||||||
|
// Next.js dev server may need manual restart
|
||||||
|
return { success: true, devMode: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: restart Next.js server to pick up new env vars
|
||||||
|
await restartNextServer()
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to apply proxy settings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== User Locale ====================
|
||||||
|
|
||||||
|
handle("get-user-locale", () => {
|
||||||
|
return getUserLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle("set-user-locale", (_event, locale: string) => {
|
||||||
|
// Validate locale is one of the supported values
|
||||||
|
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||||
|
return { success: false, error: "Invalid locale" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||||
|
// Rebuild the menu to reflect the new locale
|
||||||
|
rebuildAppMenu()
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to set locale",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Internationalization support for Electron menu
|
||||||
|
* Translations for menu labels that don't use Electron's built-in roles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getUserLocale } from "./config-manager"
|
||||||
|
|
||||||
|
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
||||||
|
|
||||||
|
export interface MenuTranslations {
|
||||||
|
// App menu (macOS only)
|
||||||
|
settings: string
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: string
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: string
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: string
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: string
|
||||||
|
switchPreset: string
|
||||||
|
managePresets: string
|
||||||
|
addConfigurationPreset: string
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: string
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: string
|
||||||
|
documentation: string
|
||||||
|
reportIssue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||||
|
en: {
|
||||||
|
// App menu
|
||||||
|
settings: "Settings...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "File",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "Edit",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "View",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "Configuration",
|
||||||
|
switchPreset: "Switch Preset",
|
||||||
|
managePresets: "Manage Presets...",
|
||||||
|
addConfigurationPreset: "Add Configuration Preset...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "Window",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "Help",
|
||||||
|
documentation: "Documentation",
|
||||||
|
reportIssue: "Report Issue",
|
||||||
|
},
|
||||||
|
|
||||||
|
zh: {
|
||||||
|
// App menu
|
||||||
|
settings: "设置...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "文件",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "编辑",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "查看",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切换预设",
|
||||||
|
managePresets: "管理预设...",
|
||||||
|
addConfigurationPreset: "添加配置预设...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "窗口",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "帮助",
|
||||||
|
documentation: "文档",
|
||||||
|
reportIssue: "报告问题",
|
||||||
|
},
|
||||||
|
|
||||||
|
ja: {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "ファイル",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "編集",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "表示",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "設定",
|
||||||
|
switchPreset: "プリセット切り替え",
|
||||||
|
managePresets: "プリセット管理...",
|
||||||
|
addConfigurationPreset: "設定プリセットを追加...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "ウインドウ",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "ヘルプ",
|
||||||
|
documentation: "ドキュメント",
|
||||||
|
reportIssue: "問題を報告",
|
||||||
|
},
|
||||||
|
|
||||||
|
"zh-Hant": {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "檔案",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "編輯",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "檢視",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切換預設",
|
||||||
|
managePresets: "管理預設...",
|
||||||
|
addConfigurationPreset: "新增配置預設...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "視窗",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "說明",
|
||||||
|
documentation: "文件",
|
||||||
|
reportIssue: "回報問題",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get menu translations for a given locale
|
||||||
|
* Falls back to English if locale is not supported
|
||||||
|
*/
|
||||||
|
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||||
|
// Check for zh-Hant before normalizing
|
||||||
|
if (
|
||||||
|
locale === "zh-Hant" ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hant") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-tw") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hk")
|
||||||
|
) {
|
||||||
|
return translations["zh-Hant"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||||
|
const normalized = locale.toLowerCase().split("-")[0]
|
||||||
|
|
||||||
|
if (normalized === "zh") return translations.zh
|
||||||
|
if (normalized === "ja") return translations.ja
|
||||||
|
return translations.en
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect system locale from Electron app
|
||||||
|
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
||||||
|
*/
|
||||||
|
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||||
|
const lower = appLocale.toLowerCase()
|
||||||
|
|
||||||
|
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||||
|
if (
|
||||||
|
lower.startsWith("zh-hant") ||
|
||||||
|
lower.startsWith("zh-tw") ||
|
||||||
|
lower.startsWith("zh-hk")
|
||||||
|
) {
|
||||||
|
return "zh-Hant"
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = lower.split("-")[0]
|
||||||
|
|
||||||
|
if (normalized === "zh") return "zh"
|
||||||
|
if (normalized === "ja") return "ja"
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get locale from stored preference or system default
|
||||||
|
* Checks config file for user's language preference first
|
||||||
|
*/
|
||||||
|
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||||
|
// Try to get from saved preference first
|
||||||
|
const savedLocale = getUserLocale()
|
||||||
|
if (savedLocale) {
|
||||||
|
return savedLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to system locale
|
||||||
|
return detectSystemLocale(appLocale)
|
||||||
|
}
|
||||||
@@ -7,9 +7,20 @@ import {
|
|||||||
getServerUrl,
|
getServerUrl,
|
||||||
isPortAvailable,
|
isPortAvailable,
|
||||||
} from "./port-manager"
|
} from "./port-manager"
|
||||||
|
import { setAppUrl } from "./window-manager"
|
||||||
|
|
||||||
let serverProcess: UtilityProcess | null = null
|
let serverProcess: UtilityProcess | null = null
|
||||||
|
|
||||||
|
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||||
|
// preset switches) can't leave two servers running
|
||||||
|
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
|
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const result = serverQueue.then(task)
|
||||||
|
serverQueue = result.catch(() => {})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the standalone server resources
|
* Get the path to the standalone server resources
|
||||||
* In packaged app: resources/standalone
|
* In packaged app: resources/standalone
|
||||||
@@ -45,7 +56,11 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
|||||||
* Start the Next.js standalone server using Electron's utilityProcess
|
* Start the Next.js standalone server using Electron's utilityProcess
|
||||||
* This API is designed for running Node.js code in the background
|
* This API is designed for running Node.js code in the background
|
||||||
*/
|
*/
|
||||||
export async function startNextServer(): Promise<string> {
|
export function startNextServer(): Promise<string> {
|
||||||
|
return runExclusive(startServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(): Promise<string> {
|
||||||
const resourcePath = getResourcePath()
|
const resourcePath = getResourcePath()
|
||||||
const serverPath = path.join(resourcePath, "server.js")
|
const serverPath = path.join(resourcePath, "server.js")
|
||||||
|
|
||||||
@@ -68,7 +83,16 @@ export async function startNextServer(): Promise<string> {
|
|||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "127.0.0.1",
|
||||||
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
|
NODE_USE_ENV_PROXY: "1",
|
||||||
|
// The preset keys are the user's own, not a server's
|
||||||
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
||||||
|
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
||||||
|
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
@@ -85,25 +109,36 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: log proxy-related env vars
|
||||||
|
console.log("Proxy env vars being passed to server:", {
|
||||||
|
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
|
||||||
|
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
|
||||||
|
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
|
||||||
|
})
|
||||||
|
|
||||||
// Use Electron's utilityProcess API for running Node.js in background
|
// Use Electron's utilityProcess API for running Node.js in background
|
||||||
// This is the recommended way to run Node.js code in Electron
|
// This is the recommended way to run Node.js code in Electron
|
||||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
const proc = utilityProcess.fork(serverPath, [], {
|
||||||
cwd: resourcePath,
|
cwd: resourcePath,
|
||||||
env,
|
env,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
|
serverProcess = proc
|
||||||
|
|
||||||
serverProcess.stdout?.on("data", (data) => {
|
proc.stdout?.on("data", (data) => {
|
||||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
serverProcess.stderr?.on("data", (data) => {
|
proc.stderr?.on("data", (data) => {
|
||||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
serverProcess.on("exit", (code) => {
|
proc.on("exit", (code) => {
|
||||||
console.log(`Next.js server exited with code ${code}`)
|
console.log(`Next.js server exited with code ${code}`)
|
||||||
serverProcess = null
|
// An old server can exit after a new one started; keep the new one
|
||||||
|
if (serverProcess === proc) {
|
||||||
|
serverProcess = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = getServerUrl()
|
const url = getServerUrl()
|
||||||
@@ -114,14 +149,39 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the Next.js server process
|
* Stop the Next.js server process and wait for it to exit
|
||||||
*/
|
*/
|
||||||
export function stopNextServer(): void {
|
export async function stopNextServer(): Promise<void> {
|
||||||
if (serverProcess) {
|
const proc = serverProcess
|
||||||
console.log("Stopping Next.js server...")
|
if (!proc) {
|
||||||
serverProcess.kill()
|
return
|
||||||
serverProcess = null
|
|
||||||
}
|
}
|
||||||
|
console.log("Stopping Next.js server...")
|
||||||
|
serverProcess = null
|
||||||
|
|
||||||
|
// Resolves true when the process exits, false after the timeout
|
||||||
|
const waitForExit = (ms: number) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
proc.once("exit", () => resolve(true))
|
||||||
|
setTimeout(() => resolve(false), ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
// Next.js waits for open requests (e.g. a streaming reply) before it
|
||||||
|
// exits, so force kill it if it is still running after 5 seconds
|
||||||
|
if (!(await waitForExit(5000)) && proc.pid) {
|
||||||
|
console.warn("Next.js server did not exit in time, force killing it")
|
||||||
|
try {
|
||||||
|
process.kill(proc.pid, "SIGKILL")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to force kill Next.js server:", error)
|
||||||
|
}
|
||||||
|
await waitForExit(2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional wait for OS to release port
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Restart the Next.js server with new environment variables
|
* Restart the Next.js server with new environment variables
|
||||||
*/
|
*/
|
||||||
export async function restartNextServer(): Promise<string> {
|
export function restartNextServer(): Promise<string> {
|
||||||
console.log("Restarting Next.js server...")
|
return runExclusive(async () => {
|
||||||
|
console.log("Restarting Next.js server...")
|
||||||
|
|
||||||
// Stop the current server
|
// Stop the current server and wait for it to exit
|
||||||
stopNextServer()
|
await stopNextServer()
|
||||||
|
|
||||||
// Wait for the port to be released
|
// Wait for the port to be released
|
||||||
await waitForServerStop()
|
await waitForServerStop()
|
||||||
|
|
||||||
// Start the server again
|
// Start the server again, and follow it if it moved to another port
|
||||||
return startNextServer()
|
const url = await startServer()
|
||||||
|
setAppUrl(url)
|
||||||
|
return url
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-13
@@ -1,4 +1,6 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import net from "node:net"
|
import net from "node:net"
|
||||||
|
import path from "node:path"
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,9 +11,11 @@ import { app } from "electron"
|
|||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Production mode uses fixed port (61337) to preserve localStorage
|
// Legacy production port — tried first to preserve localStorage for existing users
|
||||||
// Falls back to sequential ports if unavailable
|
legacyProduction: 61337,
|
||||||
production: 61337,
|
// New production port below the ephemeral range (49152-65535)
|
||||||
|
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
||||||
|
production: 13370,
|
||||||
// Maximum attempts to find an available port (fallback)
|
// Maximum attempts to find an available port (fallback)
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
@@ -21,13 +25,79 @@ const PORT_CONFIG = {
|
|||||||
*/
|
*/
|
||||||
let allocatedPort: number | null = null
|
let allocatedPort: number | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
|
||||||
|
* each origin's IndexedDB in its own folder
|
||||||
|
*/
|
||||||
|
function hasStoredData(port: number): boolean {
|
||||||
|
return existsSync(
|
||||||
|
path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"IndexedDB",
|
||||||
|
`http_127.0.0.1_${port}.indexeddb.leveldb`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two fixed production ports, the only ones whose origin (and so its
|
||||||
|
// chats and settings) is the same at every launch
|
||||||
|
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
|
|
||||||
|
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
||||||
|
|
||||||
|
/** The fixed port where a chat was last saved, if known */
|
||||||
|
function readChatPort(): number | null {
|
||||||
|
try {
|
||||||
|
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
||||||
|
return HOME_PORTS.includes(port) ? port : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChatPort(port: number): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not save the chat port:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page saved a chat: open on this port next time. Chats of the two
|
||||||
|
* ports cannot be shown together (each origin has its own storage), so the
|
||||||
|
* app opens where the user last worked. A launch that had to use the other
|
||||||
|
* port and saved nothing does not move it.
|
||||||
|
*/
|
||||||
|
export function rememberChatPort(): void {
|
||||||
|
const port = allocatedPort
|
||||||
|
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||||
|
if (readChatPort() !== port) writeChatPort(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page loaded without any chats. Before any chat was saved under this
|
||||||
|
* version (no file yet), the user's chats may be on the other fixed port,
|
||||||
|
* where an older version opened: try it first next time.
|
||||||
|
*/
|
||||||
|
export function noteNoChats(): void {
|
||||||
|
const port = allocatedPort
|
||||||
|
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||||
|
if (existsSync(chatPortFile())) return
|
||||||
|
const other = HOME_PORTS.find((p) => p !== port)
|
||||||
|
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific port is available
|
* Check if a specific port is available
|
||||||
*/
|
*/
|
||||||
export function isPortAvailable(port: number): Promise<boolean> {
|
export function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once("error", () => resolve(false))
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -39,12 +109,13 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses fixed port (61337) to preserve localStorage
|
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||||
|
* localStorage; 13370 first when only it has saved chats
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
|
* - Last resort: lets the OS assign a port (port 0)
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
* @throws Error if no available port found after max attempts
|
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
@@ -64,10 +135,24 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try preferred port first
|
// In production, first the port where a chat was last saved. Without
|
||||||
if (await isPortAvailable(preferredPort)) {
|
// one, the legacy port first to preserve existing users' data, unless
|
||||||
allocatedPort = preferredPort
|
// only the new port has data: their app started on 13370 while Windows
|
||||||
return preferredPort
|
// reserved 61337, and 61337 being free now would hide it
|
||||||
|
const chatPort = isDev ? null : readChatPort()
|
||||||
|
const candidates = isDev
|
||||||
|
? [preferredPort]
|
||||||
|
: chatPort !== null
|
||||||
|
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||||
|
: hasStoredData(PORT_CONFIG.production) &&
|
||||||
|
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||||
|
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||||
|
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
|
for (const port of candidates) {
|
||||||
|
if (await isPortAvailable(port)) {
|
||||||
|
allocatedPort = port
|
||||||
|
return port
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -84,9 +169,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
// Last resort: let the OS pick an available port
|
||||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
console.warn(
|
||||||
|
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
||||||
)
|
)
|
||||||
|
const osPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.once("listening", () => {
|
||||||
|
const addr = server.address()
|
||||||
|
const port = (addr as net.AddressInfo).port
|
||||||
|
server.close(() => resolve(port))
|
||||||
|
})
|
||||||
|
server.listen(0, "127.0.0.1")
|
||||||
|
})
|
||||||
|
allocatedPort = osPort
|
||||||
|
console.log(`OS assigned port: ${osPort}`)
|
||||||
|
return osPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,5 +212,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://localhost:${allocatedPort}`
|
return `http://127.0.0.1:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { app } from "electron"
|
||||||
|
import * as fs from "fs"
|
||||||
|
import * as path from "path"
|
||||||
|
import type { ProxyConfig } from "../electron.d"
|
||||||
|
|
||||||
|
export type { ProxyConfig }
|
||||||
|
|
||||||
|
const CONFIG_FILE = "proxy-config.json"
|
||||||
|
|
||||||
|
function getConfigPath(): string {
|
||||||
|
return path.join(app.getPath("userData"), CONFIG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load proxy configuration from JSON file
|
||||||
|
* Returns null if the user never saved proxy settings (or the file is invalid)
|
||||||
|
*/
|
||||||
|
export function loadProxyConfig(): ProxyConfig | null {
|
||||||
|
try {
|
||||||
|
const configPath = getConfigPath()
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||||
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
return data as ProxyConfig
|
||||||
|
}
|
||||||
|
console.error("Ignoring invalid proxy config:", data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load proxy config:", error)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save proxy configuration to JSON file
|
||||||
|
*/
|
||||||
|
export function saveProxyConfig(config: ProxyConfig): void {
|
||||||
|
try {
|
||||||
|
const configPath = getConfigPath()
|
||||||
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
|
// a truncated file
|
||||||
|
const tempPath = `${configPath}.tmp`
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
|
fs.renameSync(tempPath, configPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save proxy config:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply proxy configuration to process.env
|
||||||
|
* Must be called BEFORE starting the Next.js server
|
||||||
|
*/
|
||||||
|
export function applyProxyToEnv(): void {
|
||||||
|
const config = loadProxyConfig()
|
||||||
|
|
||||||
|
// No saved settings: keep proxy vars inherited from the system or .env
|
||||||
|
if (!config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.httpProxy) {
|
||||||
|
process.env.HTTP_PROXY = config.httpProxy
|
||||||
|
process.env.http_proxy = config.httpProxy
|
||||||
|
} else {
|
||||||
|
delete process.env.HTTP_PROXY
|
||||||
|
delete process.env.http_proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.httpsProxy) {
|
||||||
|
process.env.HTTPS_PROXY = config.httpsProxy
|
||||||
|
process.env.https_proxy = config.httpsProxy
|
||||||
|
} else {
|
||||||
|
delete process.env.HTTPS_PROXY
|
||||||
|
delete process.env.https_proxy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current proxy configuration (from process.env)
|
||||||
|
*/
|
||||||
|
export function getProxyConfig(): ProxyConfig {
|
||||||
|
return {
|
||||||
|
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
|
||||||
|
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user