mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
217 Commits
fix-aichat
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452e44b124 | ||
|
|
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 |
52
.github/CONTRIBUTING.md
vendored
52
.github/CONTRIBUTING.md
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
@@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
|
||||||
|
|||||||
83
.github/workflows/electron-release.yml
vendored
83
.github/workflows/electron-release.yml
vendored
@@ -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 --branch v29.3.5 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 --branch v29.3.5 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 }}
|
||||||
|
|||||||
75
.github/workflows/test.yml
vendored
Normal file
75
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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
|
||||||
@@ -65,4 +69,10 @@ CLAUDE.md
|
|||||||
.spec-workflow
|
.spec-workflow
|
||||||
|
|
||||||
# edgeone
|
# edgeone
|
||||||
.edgeone
|
.edgeone
|
||||||
|
opencode.json
|
||||||
|
ai-models.json
|
||||||
|
|
||||||
|
# local backups
|
||||||
|
*.bak
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
npx tsc --noEmit
|
||||||
|
|||||||
4
.husky/pre-push
Normal file
4
.husky/pre-push
Normal file
@@ -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
|
||||||
10
Dockerfile
10
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -19,7 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
|||||||
|
|
||||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
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!
|
||||||
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
@@ -31,7 +31,7 @@ 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)
|
||||||
@@ -40,11 +40,12 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||||
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
- [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)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
|
- [FAQ](#faq)
|
||||||
- [Star History](#star-history)
|
- [Star History](#star-history)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -98,9 +99,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 be 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).
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
|
|||||||
|
|
||||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
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 (Recommended)
|
### 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)
|
||||||
|
|
||||||
@@ -201,16 +200,18 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
|
|
||||||
## 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
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -219,6 +220,10 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
📖 **[Detailed Provider Configuration Guide](./docs/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.
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
Note that 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.
|
||||||
@@ -237,7 +242,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
|
|
||||||
## 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!
|
||||||
|
|
||||||
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!
|
||||||
|
|
||||||
@@ -245,6 +250,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)
|
||||||
|
|||||||
@@ -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 */}
|
||||||
@@ -89,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"
|
||||||
@@ -98,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">
|
||||||
@@ -108,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 */}
|
||||||
@@ -324,7 +294,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"
|
||||||
@@ -339,11 +309,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>{" "}
|
||||||
@@ -357,7 +329,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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
@@ -97,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"
|
||||||
@@ -106,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">
|
||||||
@@ -116,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">
|
||||||
@@ -339,7 +292,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"
|
||||||
@@ -354,11 +307,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>
|
||||||
@@ -372,7 +327,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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
@@ -98,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"
|
||||||
@@ -107,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{" "}
|
||||||
@@ -118,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">
|
||||||
@@ -358,7 +311,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"
|
||||||
@@ -373,11 +326,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
|
||||||
@@ -393,7 +348,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"
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"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,
|
||||||
@@ -12,54 +11,36 @@ import {
|
|||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
import { isIndexedDBUsable } from "@/lib/session-storage"
|
||||||
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<"min" | "sketch">("min")
|
||||||
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 [canPersist, setCanPersist] = useState(false)
|
||||||
|
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
||||||
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
|
)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isSavingRef = useRef(false)
|
|
||||||
const mouseOverDrawioRef = useRef(false)
|
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showSaveDialog) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
isSavingRef.current = false
|
|
||||||
}, 1000)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [showSaveDialog])
|
|
||||||
|
|
||||||
// Handle save from draw.io's built-in save button
|
|
||||||
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
|
||||||
// We use mouse position to determine if the user is interacting with draw.io
|
|
||||||
const handleDrawioSave = useCallback(() => {
|
|
||||||
if (!mouseOverDrawioRef.current) return
|
|
||||||
if (isSavingRef.current) return
|
|
||||||
isSavingRef.current = true
|
|
||||||
setShowSaveDialog(true)
|
|
||||||
}, [setShowSaveDialog])
|
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
// Restore saved locale and redirect if needed
|
||||||
@@ -92,34 +73,59 @@ 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`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const usable = await isIndexedDBUsable()
|
||||||
|
setCanPersist(usable)
|
||||||
|
setCanPersistChecked(true)
|
||||||
|
})()
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDrawioLoad = useCallback(() => {
|
||||||
await saveDiagramToStorage()
|
setIsDrawioReady(true)
|
||||||
|
onDrawioLoad()
|
||||||
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
|
const handleDrawioAutoSave = useCallback(
|
||||||
|
(data: { xml?: string }) => {
|
||||||
|
handleDiagramAutoSave(data)
|
||||||
|
// Only suppress modified state when persistence is available
|
||||||
|
if (canPersist) {
|
||||||
|
drawioRef.current?.status({ message: "", modified: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canPersist, drawioRef, handleDiagramAutoSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
await saveDiagramToStorage()
|
|
||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
setDrawioUi(newUi)
|
setDrawioUi(newUi)
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
// Check mobile - reset draw.io before crossing breakpoint
|
||||||
const isInitialRenderRef = useRef(true)
|
const isInitialRenderRef = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -128,7 +134,7 @@ export default function Home() {
|
|||||||
!isInitialRenderRef.current &&
|
!isInitialRenderRef.current &&
|
||||||
newIsMobile !== isMobileRef.current
|
newIsMobile !== isMobileRef.current
|
||||||
) {
|
) {
|
||||||
saveDiagramToStorage().catch(() => {})
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
isMobileRef.current = newIsMobile
|
isMobileRef.current = newIsMobile
|
||||||
@@ -139,7 +145,7 @@ export default function Home() {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [saveDiagramToStorage, resetDrawioReady])
|
}, [resetDrawioReady])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -167,20 +173,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 +189,52 @@ 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 && canPersistChecked && (
|
||||||
<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={handleDrawioAutoSave}
|
||||||
ui: drawioUi,
|
onExport={handleDiagramExport}
|
||||||
spin: true,
|
onLoad={handleDrawioLoad}
|
||||||
libraries: false,
|
baseUrl={drawioBaseUrl}
|
||||||
saveAndExit: false,
|
configuration={
|
||||||
noExitBtn: true,
|
canPersist
|
||||||
dark: darkMode,
|
? { confirmExit: false }
|
||||||
}}
|
: undefined
|
||||||
/>
|
}
|
||||||
) : (
|
urlParameters={{
|
||||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
ui: drawioUi,
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
spin: false,
|
||||||
|
libraries: false,
|
||||||
|
// Disable modified tracking only when persistence is available
|
||||||
|
...(canPersist && {
|
||||||
|
modified: false,
|
||||||
|
keepmodified: false,
|
||||||
|
}),
|
||||||
|
saveAndExit: false,
|
||||||
|
noSaveBtn: true,
|
||||||
|
noExitBtn: true,
|
||||||
|
dark: darkMode,
|
||||||
|
lang: currentLang,
|
||||||
|
// Enable offline mode in Electron to disable external service calls
|
||||||
|
...(isElectron && {
|
||||||
|
offline: true,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!isLoaded || !isDrawioReady) && (
|
||||||
|
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Draw.io panel is loading...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,16 +257,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}
|
||||||
|
onToggleDrawioUi={handleDrawioUiChange}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ import fs from "fs/promises"
|
|||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
import {
|
||||||
|
getAIModel,
|
||||||
|
SINGLE_SYSTEM_PROVIDERS,
|
||||||
|
supportsImageInput,
|
||||||
|
supportsPromptCaching,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
|
import {
|
||||||
|
isMinimalDiagram,
|
||||||
|
replaceHistoricalToolInputs,
|
||||||
|
validateFileParts,
|
||||||
|
} from "@/lib/chat-helpers"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -25,98 +35,12 @@ import {
|
|||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
|
import { findServerModelById } from "@/lib/server-model-config"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 120
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
|
||||||
const MAX_FILES = 5
|
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
|
||||||
function validateFileParts(messages: any[]): {
|
|
||||||
valid: boolean
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
|
||||||
const fileParts =
|
|
||||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
|
||||||
if (filePart.url?.startsWith("data:")) {
|
|
||||||
const base64Data = filePart.url.split(",")[1]
|
|
||||||
if (base64Data) {
|
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
|
||||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if diagram is minimal/empty
|
|
||||||
function isMinimalDiagram(xml: string): boolean {
|
|
||||||
const stripped = xml.replace(/\s/g, "")
|
|
||||||
return !stripped.includes('id="2"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
|
||||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
|
||||||
// Also fixes invalid/undefined inputs from interrupted streaming
|
|
||||||
function replaceHistoricalToolInputs(messages: any[]): any[] {
|
|
||||||
return messages.map((msg) => {
|
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
const replacedContent = msg.content
|
|
||||||
.map((part: any) => {
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
const toolName = part.toolName
|
|
||||||
// Fix invalid/undefined inputs from interrupted streaming
|
|
||||||
if (
|
|
||||||
!part.input ||
|
|
||||||
typeof part.input !== "object" ||
|
|
||||||
Object.keys(part.input).length === 0
|
|
||||||
) {
|
|
||||||
// Skip tool calls with invalid inputs entirely
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
toolName === "display_diagram" ||
|
|
||||||
toolName === "edit_diagram"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
input: {
|
|
||||||
placeholder:
|
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return part
|
|
||||||
})
|
|
||||||
.filter(Boolean) // Remove null entries (invalid tool calls)
|
|
||||||
return { ...msg, content: replacedContent }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`
|
||||||
@@ -166,7 +90,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
const body = await req.json()
|
||||||
|
const { messages, xml, previousXml, sessionId } = body
|
||||||
|
const customSystemMessage =
|
||||||
|
typeof body.customSystemMessage === "string"
|
||||||
|
? body.customSystemMessage.slice(0, 5000)
|
||||||
|
: ""
|
||||||
|
|
||||||
// Get user ID for Langfuse tracking and quota
|
// Get user ID for Langfuse tracking and quota
|
||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
@@ -195,7 +124,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === SERVER-SIDE QUOTA CHECK START ===
|
// === SERVER-SIDE QUOTA CHECK START ===
|
||||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||||
const hasOwnApiKey = !!(
|
const hasOwnApiKey = !!(
|
||||||
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
req.headers.get("x-ai-provider") &&
|
||||||
|
(req.headers.get("x-ai-api-key") ||
|
||||||
|
req.headers.get("x-aws-access-key-id") ||
|
||||||
|
req.headers.get("x-vertex-api-key"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||||
@@ -246,6 +178,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Read client AI provider overrides from headers
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
const provider = req.headers.get("x-ai-provider")
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
let baseUrl = req.headers.get("x-ai-base-url")
|
||||||
|
const selectedModelId = req.headers.get("x-selected-model-id")
|
||||||
|
|
||||||
// For EdgeOne provider, construct full URL from request origin
|
// For EdgeOne provider, construct full URL from request origin
|
||||||
// because createOpenAI needs absolute URL, not relative path
|
// because createOpenAI needs absolute URL, not relative path
|
||||||
@@ -257,8 +190,30 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||||
const cookieHeader = req.headers.get("cookie")
|
const cookieHeader = req.headers.get("cookie")
|
||||||
|
|
||||||
|
// Check if this is a server model with custom env var names
|
||||||
|
let serverModelConfig: {
|
||||||
|
apiKeyEnv?: string | string[]
|
||||||
|
baseUrlEnv?: string
|
||||||
|
provider?: string
|
||||||
|
} = {}
|
||||||
|
if (selectedModelId?.startsWith("server:")) {
|
||||||
|
const serverModel = await findServerModelById(selectedModelId)
|
||||||
|
console.log(
|
||||||
|
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
||||||
|
)
|
||||||
|
if (serverModel) {
|
||||||
|
serverModelConfig = {
|
||||||
|
apiKeyEnv: serverModel.apiKeyEnv,
|
||||||
|
baseUrlEnv: serverModel.baseUrlEnv,
|
||||||
|
// Use actual provider from config (client header may have incorrect value due to ID format change)
|
||||||
|
provider: serverModel.provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clientOverrides = {
|
const clientOverrides = {
|
||||||
provider,
|
// Server model provider takes precedence over client header
|
||||||
|
provider: serverModelConfig.provider || provider,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
@@ -267,6 +222,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
|
// Server model custom env var names
|
||||||
|
...serverModelConfig,
|
||||||
|
// Vertex AI credentials (Express Mode)
|
||||||
|
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||||
// Pass cookies for EdgeOne Pages authentication
|
// Pass cookies for EdgeOne Pages authentication
|
||||||
...(provider === "edgeone" &&
|
...(provider === "edgeone" &&
|
||||||
cookieHeader && {
|
cookieHeader && {
|
||||||
@@ -277,9 +236,18 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
|
||||||
|
)
|
||||||
|
|
||||||
// Get AI model with optional client overrides
|
// Get AI model with optional client overrides
|
||||||
const { model, providerOptions, headers, modelId } =
|
const {
|
||||||
getAIModel(clientOverrides)
|
model,
|
||||||
|
providerOptions,
|
||||||
|
headers,
|
||||||
|
modelId,
|
||||||
|
provider: resolvedProvider,
|
||||||
|
} = getAIModel(clientOverrides)
|
||||||
|
|
||||||
// Check if model supports prompt caching
|
// Check if model supports prompt caching
|
||||||
const shouldCache = supportsPromptCaching(modelId)
|
const shouldCache = supportsPromptCaching(modelId)
|
||||||
@@ -289,12 +257,26 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
|
const finalSystemMessage = customSystemMessage
|
||||||
|
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||||
|
: systemMessage
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts =
|
||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||||
[]
|
[]
|
||||||
|
|
||||||
|
// Check if user is sending images to a model that doesn't support them
|
||||||
|
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||||
|
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
"""md
|
"""md
|
||||||
@@ -450,37 +432,74 @@ ${userInputText}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// System messages with multiple cache breakpoints for optimal caching:
|
// System messages with multiple cache breakpoints for optimal caching:
|
||||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
|
||||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||||
// This allows: if only user message changes, both system caches are reused
|
// Some providers (e.g. MiniMax) don't support multiple system messages
|
||||||
// if XML changes, instruction cache is still reused
|
// Merge them into a single system message for compatibility
|
||||||
const systemMessages = [
|
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||||
// Cache breakpoint 1: Instructions (rarely change)
|
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||||
{
|
const isCustomOpenAIEndpoint =
|
||||||
role: "system" as const,
|
resolvedProvider === "openai" &&
|
||||||
content: systemMessage,
|
!!(
|
||||||
...(shouldCache && {
|
baseUrl ||
|
||||||
providerOptions: {
|
process.env.OPENAI_BASE_URL ||
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
(serverModelConfig.baseUrlEnv &&
|
||||||
},
|
process.env[serverModelConfig.baseUrlEnv])
|
||||||
}),
|
)
|
||||||
},
|
const isSingleSystemProvider =
|
||||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||||
{
|
|
||||||
role: "system" as const,
|
const xmlContext = `${
|
||||||
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
|
previousXml
|
||||||
...(shouldCache && {
|
? `Previous diagram XML (before user's last message):
|
||||||
providerOptions: {
|
"""xml
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
${previousXml}
|
||||||
},
|
"""
|
||||||
}),
|
|
||||||
},
|
`
|
||||||
]
|
: ""
|
||||||
|
}Current diagram XML (AUTHORITATIVE - the source of truth):
|
||||||
|
"""xml
|
||||||
|
${xml || ""}
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
|
||||||
|
|
||||||
|
const systemMessages = isSingleSystemProvider
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: `${finalSystemMessage}\n\n${xmlContext}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Cache breakpoint 1: Instructions (+ optional custom instructions)
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: finalSystemMessage,
|
||||||
|
...(shouldCache && {
|
||||||
|
providerOptions: {
|
||||||
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: xmlContext,
|
||||||
|
...(shouldCache && {
|
||||||
|
providerOptions: {
|
||||||
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
|
abortSignal: req.signal,
|
||||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||||
}),
|
}),
|
||||||
@@ -508,6 +527,13 @@ ${userInputText}
|
|||||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||||
// Fix `= "` instead of `: "`
|
// Fix `= "` instead of `: "`
|
||||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||||
|
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
||||||
|
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
||||||
|
// Example: y="-20\" should be y=\"-20\"
|
||||||
|
inputToRepair = inputToRepair.replace(
|
||||||
|
/(\w+)="([^"]*?)\\"/g,
|
||||||
|
'$1=\\"$2\\"',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Use jsonrepair to fix truncated JSON
|
// Use jsonrepair to fix truncated JSON
|
||||||
const repairedInput = jsonrepair(inputToRepair)
|
const repairedInput = jsonrepair(inputToRepair)
|
||||||
@@ -687,7 +713,7 @@ Available libraries:
|
|||||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||||
- Business: bpmn, lean_mapping
|
- Business: bpmn, lean_mapping
|
||||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||||
- UI/Mockups: android
|
- UI/Mockups: android, material_design
|
||||||
- Enterprise: citrix, sap, mscae, atlassian
|
- Enterprise: citrix, sap, mscae, atlassian
|
||||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||||
- Icons: webicons
|
- Icons: webicons
|
||||||
@@ -732,7 +758,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
if (
|
if (
|
||||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||||
) {
|
) {
|
||||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
|
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`[get_shape_library] Error loading "${library}":`,
|
`[get_shape_library] Error loading "${library}":`,
|
||||||
|
|||||||
128
app/api/parse-url/route.ts
Normal file
128
app/api/parse-url/route.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { extract } from "@extractus/article-extractor"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import TurndownService from "turndown"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
|
||||||
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { url } = await req.json()
|
||||||
|
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "URL is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid URL format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF protection
|
||||||
|
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot access private/internal URLs" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const headController = new AbortController()
|
||||||
|
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
||||||
|
try {
|
||||||
|
const headResponse = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal: headController.signal,
|
||||||
|
})
|
||||||
|
const contentType = headResponse.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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"HEAD pre-check failed, proceeding with extraction:",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(headTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let article
|
||||||
|
try {
|
||||||
|
article = await extract(url, undefined, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Timed out while fetching URL content" },
|
||||||
|
{ status: 504 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/server-models/route.ts
Normal file
14
app/api/server-models/route.ts
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
136
app/api/validate-diagram/route.ts
Normal file
136
app/api/validate-diagram/route.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint for VLM-based diagram validation.
|
||||||
|
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { streamObject } from "ai"
|
||||||
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
|
import {
|
||||||
|
type ValidationResult,
|
||||||
|
ValidationResultSchema,
|
||||||
|
} from "@/lib/validation-schema"
|
||||||
|
|
||||||
|
export const maxDuration = 30
|
||||||
|
|
||||||
|
interface ValidateDiagramRequest {
|
||||||
|
imageData: string // Base64 PNG data URL
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default valid result for disabled/error cases
|
||||||
|
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
suggestions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a streaming response for useObject compatibility.
|
||||||
|
* useObject expects text stream format, not plain JSON.
|
||||||
|
*/
|
||||||
|
function createStreamingResponse(result: ValidationResult): Response {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
// Stream the JSON as text (useObject parses this)
|
||||||
|
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if VLM validation is enabled (default: true)
|
||||||
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
|
if (!enableValidation) {
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
|
const { imageData, sessionId } = body
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing imageData" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image data format
|
||||||
|
if (
|
||||||
|
!imageData.startsWith("data:image/png;base64,") &&
|
||||||
|
!imageData.startsWith("data:image/")
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid image data format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the validation model
|
||||||
|
let model
|
||||||
|
try {
|
||||||
|
model = getValidationModel()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[validate-diagram] Validation model not available:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
// Return valid if no vision model is configured
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
|
const timeout =
|
||||||
|
Math.max(
|
||||||
|
1000,
|
||||||
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
|
) || 10000
|
||||||
|
|
||||||
|
// Stream the VLM response for useObject consumption
|
||||||
|
const result = streamObject({
|
||||||
|
model,
|
||||||
|
schema: ValidationResultSchema,
|
||||||
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
image: imageData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Please analyze this diagram for visual quality issues.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
abortSignal: AbortSignal.timeout(timeout),
|
||||||
|
onFinish: ({ object }) => {
|
||||||
|
if (sessionId && object) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toTextStreamResponse()
|
||||||
|
} catch (error) {
|
||||||
|
// Log with session context if available
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
|
// On error, return valid to not block the user
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,74 +3,18 @@ import { createAnthropic } from "@ai-sdk/anthropic"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
import { createGateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
|
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
/**
|
|
||||||
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
|
||||||
*/
|
|
||||||
function isPrivateUrl(urlString: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
const hostname = url.hostname.toLowerCase()
|
|
||||||
|
|
||||||
// Block localhost
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "::1"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block AWS/cloud metadata endpoints
|
|
||||||
if (
|
|
||||||
hostname === "169.254.169.254" ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private IPv4 ranges
|
|
||||||
const ipv4Match = hostname.match(
|
|
||||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
||||||
)
|
|
||||||
if (ipv4Match) {
|
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if (a === 10) return true
|
|
||||||
// 172.16.0.0/12
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if (a === 192 && b === 168) return true
|
|
||||||
// 169.254.0.0/16 (link-local)
|
|
||||||
if (a === 169 && b === 254) return true
|
|
||||||
// 127.0.0.0/8 (loopback)
|
|
||||||
if (a === 127) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block common internal hostnames
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".localhost")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
// Invalid URL - block it
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -80,6 +24,8 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
|
// Vertex AI specific
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -93,6 +39,8 @@ export async function POST(req: Request) {
|
|||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
|
// Note: Express Mode only needs vertexApiKey
|
||||||
|
vertexApiKey,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -103,7 +51,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
if (baseUrl && !allowPrivateUrls && 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,6 +69,16 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (provider === "vertexai") {
|
||||||
|
if (!vertexApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "Vertex AI API key is required for Express Mode",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "API key is required" },
|
{ valid: false, error: "API key is required" },
|
||||||
@@ -158,6 +116,15 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "vertexai": {
|
||||||
|
const vertex = createVertex({
|
||||||
|
apiKey: vertexApiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = vertex(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const azure = createOpenAI({
|
const azure = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -202,17 +169,28 @@ export async function POST(req: Request) {
|
|||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const sf = createOpenAI({
|
const sf = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||||
})
|
})
|
||||||
model = sf.chat(modelId)
|
model = sf.chat(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ollama": {
|
case "ollama": {
|
||||||
const ollama = createOllama({
|
// SECURITY: Mirror ai-providers.ts guard — only use server
|
||||||
baseURL: baseUrl || "http://localhost:11434",
|
// OLLAMA_API_KEY when the URL is also from server config.
|
||||||
|
const ollamaApiKey = baseUrl
|
||||||
|
? apiKey || undefined
|
||||||
|
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
||||||
|
const ollamaProvider = createOllama({
|
||||||
|
baseURL:
|
||||||
|
baseUrl ||
|
||||||
|
process.env.OLLAMA_BASE_URL ||
|
||||||
|
"https://ollama.com/api",
|
||||||
|
...(ollamaApiKey && {
|
||||||
|
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
model = ollama(modelId)
|
model = ollamaProvider(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,13 +229,148 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// ByteDance Doubao uses DeepSeek-compatible API
|
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||||
const doubao = createDeepSeek({
|
const doubaoBaseUrl =
|
||||||
|
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("deepseek") ||
|
||||||
|
lowerModelId.includes("kimi")
|
||||||
|
) {
|
||||||
|
const doubao = createDeepSeek({
|
||||||
|
apiKey,
|
||||||
|
baseURL: doubaoBaseUrl,
|
||||||
|
})
|
||||||
|
model = doubao(modelId)
|
||||||
|
} else {
|
||||||
|
const doubao = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: doubaoBaseUrl,
|
||||||
|
})
|
||||||
|
model = doubao.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "modelscope": {
|
||||||
|
const baseURL =
|
||||||
|
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: modelId,
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Say 'OK'" },
|
||||||
|
],
|
||||||
|
max_tokens: 20,
|
||||||
|
stream: true,
|
||||||
|
enable_thinking: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`ModelScope API error (${response.status}): ${errorText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type") || ""
|
||||||
|
const isValidStreamingResponse =
|
||||||
|
response.status === 200 &&
|
||||||
|
(contentType.includes("text/event-stream") ||
|
||||||
|
contentType.includes("application/json"))
|
||||||
|
|
||||||
|
if (!isValidStreamingResponse) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response format: ${contentType}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
response.body.cancel().catch(() => {
|
||||||
|
/* Ignore cancellation errors */
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
responseTime,
|
||||||
|
note: "ModelScope model validated (using streaming API)",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[validate-model] ModelScope validation failed:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "minimax": {
|
||||||
|
const rawUrl =
|
||||||
|
baseUrl ||
|
||||||
|
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
||||||
|
"https://api.minimaxi.com/anthropic"
|
||||||
|
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
||||||
|
normalizeMiniMaxBaseURL(rawUrl)
|
||||||
|
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
const minimax = createAnthropic({
|
||||||
|
apiKey,
|
||||||
|
baseURL: minimaxBaseUrl,
|
||||||
|
})
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
} else {
|
||||||
|
const minimax = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: minimaxBaseUrl,
|
||||||
|
})
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "kimi":
|
||||||
|
case "qiniu":
|
||||||
|
case "novita": {
|
||||||
|
const baseURL =
|
||||||
|
baseUrl ||
|
||||||
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||||
|
""
|
||||||
|
|
||||||
|
if (!baseURL) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: `No base URL configured for provider: ${provider}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL:
|
baseURL,
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
})
|
})
|
||||||
model = doubao(modelId)
|
model = openai.chat(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
"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 {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { 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
|
||||||
@@ -135,18 +147,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?: (data: Map<string, UrlData>) => void
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -154,92 +172,221 @@ 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
|
||||||
|
|
||||||
|
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)
|
const shouldSend =
|
||||||
adjustTextareaHeight()
|
sendShortcut === "enter"
|
||||||
}
|
? e.key === "Enter" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.metaKey
|
||||||
|
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
if (shouldSend) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
e.preventDefault()
|
||||||
e.preventDefault()
|
const form = e.currentTarget.closest("form")
|
||||||
const form = e.currentTarget.closest("form")
|
if (form && input.trim() && !isDisabled) {
|
||||||
if (form && input.trim() && !isDisabled) {
|
form.requestSubmit()
|
||||||
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(
|
||||||
|
newFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
|
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (isDisabled) return
|
||||||
|
|
||||||
|
const droppedFiles = e.dataTransfer.files
|
||||||
|
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
|
isValidFileType(file),
|
||||||
|
)
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
imageFiles,
|
supportedFiles,
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -248,132 +395,98 @@ 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,
|
try {
|
||||||
)
|
const existing = urlData
|
||||||
showValidationErrors(errors, dict)
|
? new Map(urlData)
|
||||||
if (validFiles.length > 0) {
|
: new Map<string, UrlData>()
|
||||||
onFileChange([...files, ...validFiles])
|
existing.set(url, {
|
||||||
|
url,
|
||||||
|
title: url,
|
||||||
|
content: "",
|
||||||
|
charCount: 0,
|
||||||
|
isExtracting: true,
|
||||||
|
})
|
||||||
|
onUrlChange(existing)
|
||||||
|
|
||||||
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
|
const newUrlData = new Map(existing)
|
||||||
|
newUrlData.set(url, data)
|
||||||
|
onUrlChange(newUrlData)
|
||||||
|
|
||||||
|
setShowUrlDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
// Remove the URL from the data map on error
|
||||||
|
const newUrlData = urlData
|
||||||
|
? new Map(urlData)
|
||||||
|
: new Map<string, UrlData>()
|
||||||
|
newUrlData.delete(url)
|
||||||
|
onUrlChange(newUrlData)
|
||||||
|
showErrorToast(
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to extract URL content"}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsExtractingUrl(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
const next = new Map(urlData)
|
||||||
setIsDragging(false)
|
next.delete(url)
|
||||||
}
|
onUrlChange(next)
|
||||||
|
}
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
: undefined
|
||||||
e.preventDefault()
|
}
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
|
||||||
isValidFileType(file),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
supportedFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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 +507,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,6 +528,32 @@ 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}
|
||||||
@@ -432,41 +573,66 @@ 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 || !input.trim()}
|
||||||
)}
|
size="sm"
|
||||||
</Button>
|
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>
|
defaultFilename={`diagram-${new Date()
|
||||||
)
|
.toISOString()
|
||||||
}
|
.slice(0, 10)}`}
|
||||||
|
/>
|
||||||
|
{onUrlChange && (
|
||||||
|
<UrlInputDialog
|
||||||
|
open={showUrlDialog}
|
||||||
|
onOpenChange={setShowUrlDialog}
|
||||||
|
onSubmit={handleUrlExtract}
|
||||||
|
isExtracting={isExtractingUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={showSaveAsTemplate}
|
||||||
|
onOpenChange={setShowSaveAsTemplate}
|
||||||
|
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||||
|
initialPrompt={input.trim()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
|
Link,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
|
||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
@@ -26,6 +26,13 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
|
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||||
|
import Image from "@/components/image-with-basepath"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
@@ -33,18 +40,9 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
isMxCellXmlComplete,
|
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import ExamplePanel from "./chat-example-panel"
|
|
||||||
import { CodeBlock } from "./code-block"
|
|
||||||
|
|
||||||
interface DiagramOperation {
|
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -58,76 +56,26 @@ function getCompleteOperations(
|
|||||||
["update", "add", "delete"].includes(op.operation) &&
|
["update", "add", "delete"].includes(op.operation) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
// delete doesn't need new_xml, update/add do
|
|
||||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool part interface for type safety
|
|
||||||
interface ToolPartLike {
|
|
||||||
type: string
|
|
||||||
toolCallId: string
|
|
||||||
state?: string
|
|
||||||
input?: {
|
|
||||||
xml?: string
|
|
||||||
operations?: DiagramOperation[]
|
|
||||||
} & Record<string, unknown>
|
|
||||||
output?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{operations.map((op, index) => (
|
|
||||||
<div
|
|
||||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
||||||
op.operation === "delete"
|
|
||||||
? "text-red-600"
|
|
||||||
: op.operation === "add"
|
|
||||||
? "text-green-600"
|
|
||||||
: "text-blue-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{op.operation}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
cell_id: {op.cell_id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{op.new_xml && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
|
||||||
{op.new_xml}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file sections (PDF or text files)
|
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
||||||
interface TextSection {
|
interface TextSection {
|
||||||
type: "text" | "file"
|
type: "text" | "file" | "url"
|
||||||
content: string
|
content: string
|
||||||
filename?: string
|
filename?: string
|
||||||
charCount?: number
|
charCount?: number
|
||||||
fileType?: "pdf" | "text"
|
fileType?: "pdf" | "text" | "url"
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||||
const sections: TextSection[] = []
|
const sections: TextSection[] = []
|
||||||
// Match [PDF: filename] or [File: filename] patterns
|
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
||||||
const filePattern =
|
const filePattern =
|
||||||
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -138,28 +86,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
|||||||
sections.push({ type: "text", content: beforeText })
|
sections.push({ type: "text", content: beforeText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file section
|
// Add file/url section
|
||||||
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
const sectionType = match[1].toLowerCase()
|
||||||
|
const fileType =
|
||||||
|
sectionType === "pdf"
|
||||||
|
? "pdf"
|
||||||
|
: sectionType === "url"
|
||||||
|
? "url"
|
||||||
|
: "text"
|
||||||
const filename = match[2].trim()
|
const filename = match[2].trim()
|
||||||
const fileContent = match[3].trim()
|
const content = match[3].trim()
|
||||||
sections.push({
|
sections.push({
|
||||||
type: "file",
|
type: sectionType === "url" ? "url" : "file",
|
||||||
content: fileContent,
|
content: content,
|
||||||
filename,
|
filename,
|
||||||
charCount: fileContent.length,
|
charCount: content.length,
|
||||||
fileType,
|
fileType,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length
|
lastIndex = match.index + match[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last file section
|
// Add remaining text after last section
|
||||||
const remainingText = text.slice(lastIndex).trim()
|
const remainingText = text.slice(lastIndex).trim()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
sections.push({ type: "text", content: remainingText })
|
sections.push({ type: "text", content: remainingText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no file sections found, return original text
|
// If no file/url sections found, return original text
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
sections.push({ type: "text", content: text })
|
sections.push({ type: "text", content: text })
|
||||||
}
|
}
|
||||||
@@ -178,11 +132,18 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
||||||
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
updatedAt: number
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
@@ -193,6 +154,17 @@ interface ChatMessageDisplayProps {
|
|||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||||
|
isRestored?: boolean
|
||||||
|
sessions?: SessionMetadata[]
|
||||||
|
onSelectSession?: (id: string) => void
|
||||||
|
onDeleteSession?: (id: string) => void
|
||||||
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||||
|
validationStates?: Record<string, ValidationState>
|
||||||
|
onImproveWithSuggestions?: (feedback: string) => void
|
||||||
|
onSendTemplate?: (
|
||||||
|
template: import("@/lib/template-storage").Template,
|
||||||
|
) => void
|
||||||
|
currentInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -205,14 +177,37 @@ export function ChatMessageDisplay({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
|
isRestored = false,
|
||||||
|
sessions = [],
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
loadedMessageIdsRef,
|
||||||
|
validationStates = {},
|
||||||
|
onImproveWithSuggestions,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
const processedToolCalls = processedToolCallsRef
|
const processedToolCalls = processedToolCallsRef
|
||||||
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
||||||
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Reset refs when messages become empty (new chat or session switch)
|
||||||
|
// This ensures cached examples work correctly after starting a new session
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
previousXML.current = ""
|
||||||
|
lastProcessedXmlRef.current.clear()
|
||||||
|
// Note: processedToolCalls is passed from parent, so we clear it too
|
||||||
|
processedToolCalls.current.clear()
|
||||||
|
// Scroll to top to show newest history items
|
||||||
|
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
||||||
|
}
|
||||||
|
}, [messages.length, processedToolCalls])
|
||||||
// Debounce streaming diagram updates - store pending XML and timeout
|
// Debounce streaming diagram updates - store pending XML and timeout
|
||||||
const pendingXmlRef = useRef<string | null>(null)
|
const pendingXmlRef = useRef<string | null>(null)
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
@@ -250,6 +245,10 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
|
// Track "Save as Template" dialog
|
||||||
|
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
const setCopyState = (
|
const setCopyState = (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -283,7 +282,7 @@ export function ChatMessageDisplay({
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopyState(messageId, isToolCall, true)
|
setCopyState(messageId, isToolCall, true)
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
// Fallback for non-secure contexts (HTTP) or permission denied
|
||||||
const textarea = document.createElement("textarea")
|
const textarea = document.createElement("textarea")
|
||||||
textarea.value = text
|
textarea.value = text
|
||||||
@@ -347,7 +346,6 @@ export function ChatMessageDisplay({
|
|||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
let currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
const startTime = performance.now()
|
|
||||||
|
|
||||||
// During streaming (showToast=false), extract only complete mxCell elements
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
@@ -371,14 +369,8 @@ export function ChatMessageDisplay({
|
|||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
// Use console.warn instead of console.error to avoid triggering
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
// Next.js dev mode error overlay for expected streaming states
|
|
||||||
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
// Only log as error and show toast if this is the final XML
|
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] Malformed XML detected in final output",
|
|
||||||
)
|
|
||||||
toast.error(dict.errors.malformedXml)
|
toast.error(dict.errors.malformedXml)
|
||||||
}
|
}
|
||||||
return // Skip this update
|
return // Skip this update
|
||||||
@@ -392,18 +384,12 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
const xmlProcessTime = performance.now() - startTime
|
|
||||||
|
|
||||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
// The quick DOM parse check above catches malformed XML
|
// The quick DOM parse check above catches malformed XML
|
||||||
// Full validation runs on final output (showToast=true)
|
// Full validation runs on final output (showToast=true)
|
||||||
if (!showToast) {
|
if (!showToast) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
const loadStartTime = performance.now()
|
|
||||||
onDisplayChart(replacedXML, true)
|
onDisplayChart(replacedXML, true)
|
||||||
console.log(
|
|
||||||
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,30 +399,12 @@ export function ChatMessageDisplay({
|
|||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
// Use fixed XML if available, otherwise use original
|
// Use fixed XML if available, otherwise use original
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
const xmlToLoad = validation.fixed || replacedXML
|
||||||
if (validation.fixes.length > 0) {
|
|
||||||
console.log(
|
|
||||||
"[ChatMessageDisplay] Auto-fixed XML issues:",
|
|
||||||
validation.fixes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Skip validation in loadDiagram since we already validated above
|
|
||||||
const loadStartTime = performance.now()
|
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
console.log(
|
|
||||||
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
|
||||||
validation.error,
|
|
||||||
)
|
|
||||||
toast.error(dict.errors.validationFailed)
|
toast.error(dict.errors.validationFailed)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error processing XML:", error)
|
||||||
"[ChatMessageDisplay] Error processing XML:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.failedToProcess)
|
toast.error(dict.errors.failedToProcess)
|
||||||
@@ -447,8 +415,22 @@ export function ChatMessageDisplay({
|
|||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Track previous message count to detect bulk loads vs streaming
|
||||||
|
const prevMessageCountRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current && messages.length > 0) {
|
||||||
|
const prevCount = prevMessageCountRef.current
|
||||||
|
const currentCount = messages.length
|
||||||
|
prevMessageCountRef.current = currentCount
|
||||||
|
|
||||||
|
// Bulk load (session restore) - instant scroll, no animation
|
||||||
|
if (prevCount === 0 || currentCount - prevCount > 1) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single message added - smooth scroll
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
@@ -472,11 +454,15 @@ export function ChatMessageDisplay({
|
|||||||
const toolPart = part as ToolPartLike
|
const toolPart = part as ToolPartLike
|
||||||
const { toolCallId, state, input } = toolPart
|
const { toolCallId, state, input } = toolPart
|
||||||
|
|
||||||
|
// Auto-collapse on completion, but only if user hasn't manually toggled
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => {
|
||||||
...prev,
|
// Only auto-collapse if not already set (user hasn't interacted)
|
||||||
[toolCallId]: false,
|
if (prev[toolCallId] === undefined) {
|
||||||
}))
|
return { ...prev, [toolCallId]: false }
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -666,202 +652,21 @@ export function ChatMessageDisplay({
|
|||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
|
||||||
const callId = part.toolCallId
|
|
||||||
const { state, input, output } = part
|
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
|
||||||
const toolName = part.type?.replace("tool-", "")
|
|
||||||
const isCopied = copiedToolCallId === callId
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpandedTools((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[callId]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
|
||||||
switch (name) {
|
|
||||||
case "display_diagram":
|
|
||||||
return "Generate Diagram"
|
|
||||||
case "edit_diagram":
|
|
||||||
return "Edit Diagram"
|
|
||||||
case "get_shape_library":
|
|
||||||
return "Get Shape Library"
|
|
||||||
default:
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
let textToCopy = ""
|
|
||||||
|
|
||||||
if (input && typeof input === "object") {
|
|
||||||
if (input.xml) {
|
|
||||||
textToCopy = input.xml
|
|
||||||
} else if (
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations)
|
|
||||||
) {
|
|
||||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
||||||
} else if (Object.keys(input).length > 0) {
|
|
||||||
textToCopy = JSON.stringify(input, null, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
typeof output === "string"
|
|
||||||
) {
|
|
||||||
textToCopy = output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textToCopy) {
|
|
||||||
copyMessageToClipboard(callId, textToCopy, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={callId}
|
|
||||||
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground/80">
|
|
||||||
{getToolDisplayName(toolName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{state === "input-streaming" && (
|
|
||||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
{state === "output-available" && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
||||||
{dict.tools.complete}
|
|
||||||
</span>
|
|
||||||
{isExpanded && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
copiedToolCallId === callId
|
|
||||||
? dict.chat.copied
|
|
||||||
: copyFailedToolCallId ===
|
|
||||||
callId
|
|
||||||
? dict.chat.failedToCopy
|
|
||||||
: dict.chat.copyResponse
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="w-4 h-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
// Check if this is a truncation (incomplete XML) vs real error
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return isTruncated ? (
|
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
||||||
Truncated
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{input && Object.keys(input).length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{input && isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
||||||
{typeof input === "object" && input.xml ? (
|
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations) ? (
|
|
||||||
<OperationsDisplay operations={input.operations} />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
Object.keys(input).length > 0 ? (
|
|
||||||
<CodeBlock
|
|
||||||
code={JSON.stringify(input, null, 2)}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{output &&
|
|
||||||
state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
||||||
>
|
|
||||||
{isTruncated
|
|
||||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
|
||||||
: output}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{/* Show get_shape_library output on success */}
|
|
||||||
{output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
state === "output-available" &&
|
|
||||||
isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40">
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
Library loaded (
|
|
||||||
{typeof output === "string" ? output.length : 0}{" "}
|
|
||||||
chars)
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
||||||
{typeof output === "string"
|
|
||||||
? output.substring(0, 800) +
|
|
||||||
(output.length > 800 ? "\n..." : "")
|
|
||||||
: String(output)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
{messages.length === 0 ? (
|
<div ref={scrollTopRef} />
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
{messages.length === 0 && isRestored ? (
|
||||||
) : (
|
<ChatLobby
|
||||||
|
sessions={sessions}
|
||||||
|
onSelectSession={onSelectSession || (() => {})}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
dict={dict}
|
||||||
|
/>
|
||||||
|
) : messages.length === 0 ? null : (
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 px-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText =
|
||||||
@@ -881,13 +686,21 @@ export function ChatMessageDisplay({
|
|||||||
.slice(messageIndex + 1)
|
.slice(messageIndex + 1)
|
||||||
.every((m) => m.role !== "user"))
|
.every((m) => m.role !== "user"))
|
||||||
const isEditing = editingMessageId === message.id
|
const isEditing = editingMessageId === message.id
|
||||||
|
// Skip animation for loaded messages (from session restore)
|
||||||
|
const isRestoredMessage =
|
||||||
|
loadedMessageIdsRef?.current.has(message.id) ??
|
||||||
|
false
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
||||||
style={{
|
style={
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
isRestoredMessage
|
||||||
}}
|
? undefined
|
||||||
|
: {
|
||||||
|
animationDelay: `${messageIndex * 50}ms`,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" &&
|
||||||
userMessageText &&
|
userMessageText &&
|
||||||
@@ -948,8 +761,42 @@ export function ChatMessageDisplay({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Save as Template button - only for user messages */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSaveAsTemplateMessageId(
|
||||||
|
message.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
title={
|
||||||
|
dict.templates
|
||||||
|
?.saveAsTemplate ||
|
||||||
|
"Save as Template"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Save as Template Dialog */}
|
||||||
|
{saveAsTemplateMessageId === message.id && (
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open)
|
||||||
|
setSaveAsTemplateMessageId(null)
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setSaveAsTemplateMessageId(null)
|
||||||
|
}}
|
||||||
|
initialPrompt={getUserOriginalText(
|
||||||
|
message,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%] min-w-0">
|
||||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||||
{message.role === "assistant" &&
|
{message.role === "assistant" &&
|
||||||
@@ -984,6 +831,9 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
|
defaultOpen={
|
||||||
|
!isRestoredMessage
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
@@ -1126,9 +976,56 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
return renderToolPart(
|
const toolPart = group
|
||||||
group
|
.parts[0] as ToolPartLike
|
||||||
.parts[0] as ToolPartLike,
|
const toolCallId =
|
||||||
|
toolPart.toolCallId
|
||||||
|
const isDisplayDiagram =
|
||||||
|
toolPart.type ===
|
||||||
|
"tool-display_diagram"
|
||||||
|
const validationState =
|
||||||
|
validationStates[
|
||||||
|
toolCallId
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-tool-${group.startIndex}`}
|
||||||
|
>
|
||||||
|
<ToolCallCard
|
||||||
|
part={
|
||||||
|
toolPart
|
||||||
|
}
|
||||||
|
expandedTools={
|
||||||
|
expandedTools
|
||||||
|
}
|
||||||
|
setExpandedTools={
|
||||||
|
setExpandedTools
|
||||||
|
}
|
||||||
|
onCopy={
|
||||||
|
copyMessageToClipboard
|
||||||
|
}
|
||||||
|
copiedToolCallId={
|
||||||
|
copiedToolCallId
|
||||||
|
}
|
||||||
|
copyFailedToolCallId={
|
||||||
|
copyFailedToolCallId
|
||||||
|
}
|
||||||
|
dict={dict}
|
||||||
|
/>
|
||||||
|
{/* Show validation card for display_diagram tools */}
|
||||||
|
{isDisplayDiagram &&
|
||||||
|
validationState && (
|
||||||
|
<ValidationCard
|
||||||
|
state={
|
||||||
|
validationState
|
||||||
|
}
|
||||||
|
onImproveWithSuggestions={
|
||||||
|
onImproveWithSuggestions
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1242,12 +1139,14 @@ export function ChatMessageDisplay({
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
section.type ===
|
section.type ===
|
||||||
"file"
|
"file" ||
|
||||||
|
section.type ===
|
||||||
|
"url"
|
||||||
) {
|
) {
|
||||||
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
expandedPdfSections[
|
expandedPdfSections[
|
||||||
pdfKey
|
sectionKey
|
||||||
] ??
|
] ??
|
||||||
false
|
false
|
||||||
const charDisplay =
|
const charDisplay =
|
||||||
@@ -1256,10 +1155,27 @@ export function ChatMessageDisplay({
|
|||||||
1000
|
1000
|
||||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||||
: section.charCount
|
: section.charCount
|
||||||
|
|
||||||
|
// Icon selector
|
||||||
|
const Icon =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? FileText
|
||||||
|
: section.fileType ===
|
||||||
|
"url"
|
||||||
|
? Link
|
||||||
|
: FileCode
|
||||||
|
|
||||||
|
const iconColor =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-blue-700"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
pdfKey
|
sectionKey
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -1274,7 +1190,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[pdfKey]:
|
[sectionKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1282,13 +1198,10 @@ export function ChatMessageDisplay({
|
|||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{section.fileType ===
|
<Icon
|
||||||
"pdf" ? (
|
className={`h-4 w-4 ${iconColor}`}
|
||||||
<FileText className="h-4 w-4 text-red-500" />
|
/>
|
||||||
) : (
|
<span className="text-xs font-medium truncate max-w-[200px]">
|
||||||
<FileCode className="h-4 w-4 text-blue-500" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{
|
{
|
||||||
section.filename
|
section.filename
|
||||||
}
|
}
|
||||||
@@ -1308,7 +1221,7 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
|
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
|
||||||
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
||||||
{
|
{
|
||||||
section.content
|
section.content
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
352
components/chat/ChatLobby.tsx
Normal file
352
components/chat/ChatLobby.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"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(false)
|
||||||
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (panelVisibility.quickExamples) {
|
||||||
|
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show history + collapsible examples when there are sessions
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
{/* Recent Chats Section */}
|
||||||
|
{panelVisibility.recentChats && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
|
</p>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
|
"Search chats..."
|
||||||
|
}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions
|
||||||
|
.filter((session) =>
|
||||||
|
session.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((session) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
).length === 0 &&
|
||||||
|
searchQuery && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{dict.sessionHistory?.noResults ||
|
||||||
|
"No chats found"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible My Templates Section */}
|
||||||
|
{panelVisibility.myTemplates && (
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.templates?.myTemplates || "My Templates"}
|
||||||
|
</span>
|
||||||
|
{templatesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{templatesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible Quick Examples Section */}
|
||||||
|
{panelVisibility.quickExamples && (
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.examples?.quickExamples || "Quick Examples"}
|
||||||
|
</span>
|
||||||
|
{examplesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{examplesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ExamplePanel
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
minimal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.sessionHistory?.deleteTitle ||
|
||||||
|
"Delete this chat?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.sessionHistory?.deleteDescription ||
|
||||||
|
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (sessionToDelete && onDeleteSession) {
|
||||||
|
onDeleteSession(sessionToDelete)
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setSessionToDelete(null)
|
||||||
|
}}
|
||||||
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||||
|
>
|
||||||
|
{dict.common.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Plus } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type TemplateCreateInput,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
initialPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateCreateDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset form when dialog opens with the latest initialPrompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setPrompt(initialPrompt)
|
||||||
|
setPinned(false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, initialPrompt])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: TemplateCreateInput = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await createTemplate(input)
|
||||||
|
if (template) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create template:", err)
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{dict.templates.createTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.createDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt" className="text-foreground">
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-foreground">
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.templates.createButton}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Edit2 } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
template: Template | null
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
template,
|
||||||
|
onSuccess,
|
||||||
|
}: TemplateEditDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Populate form when template changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (template) {
|
||||||
|
setTitle(template.title || "")
|
||||||
|
setDescription(template.description || "")
|
||||||
|
setPrompt(template.prompt || "")
|
||||||
|
setPinned(template.pinned || false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateTemplate(template.id, updates)
|
||||||
|
if (updated) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update template:", err)
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
{dict.templates.editTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.editDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-prompt"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-title"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="edit-pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.common.save}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
622
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit2,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
deleteTemplate,
|
||||||
|
duplicateTemplate,
|
||||||
|
exportTemplates,
|
||||||
|
getAllTemplates,
|
||||||
|
importTemplates,
|
||||||
|
incrementClickCount,
|
||||||
|
incrementRunCount,
|
||||||
|
searchTemplates,
|
||||||
|
type Template,
|
||||||
|
updateTemplate,
|
||||||
|
validateImportData,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||||
|
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||||
|
|
||||||
|
interface TemplatePanelProps {
|
||||||
|
setInput: (input: string) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||||
|
if (!timestamp) return neverUsedText
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = now - timestamp
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||||
|
if (diffMins < 1) return rtf.format(0, "minute")
|
||||||
|
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||||
|
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||||
|
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||||
|
} catch {
|
||||||
|
// Fallback if Intl.RelativeTimeFormat is not available
|
||||||
|
if (diffMins < 1) return "<1m ago"
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatePanel({
|
||||||
|
setInput,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
|
}: TemplatePanelProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||||
|
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [importMessage, setImportMessage] = useState<{
|
||||||
|
type: "success" | "error"
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
const result = await getAllTemplates()
|
||||||
|
setTemplates(result)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter templates by search query
|
||||||
|
const filteredTemplates = searchQuery.trim()
|
||||||
|
? searchTemplates(templates, searchQuery)
|
||||||
|
: templates
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates()
|
||||||
|
}, [loadTemplates])
|
||||||
|
|
||||||
|
const handleCreateSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (template: Template) => {
|
||||||
|
setTemplateToEdit(template)
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDuplicate = async (template: Template) => {
|
||||||
|
const duplicated = await duplicateTemplate(
|
||||||
|
template.id,
|
||||||
|
dict.templates.copySuffix || "(copy)",
|
||||||
|
)
|
||||||
|
if (duplicated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (template: Template) => {
|
||||||
|
setTemplateToDelete(template)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!templateToDelete) return
|
||||||
|
const success = await deleteTemplate(templateToDelete.id)
|
||||||
|
if (success) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setTemplateToDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async (template: Template) => {
|
||||||
|
const updated = await updateTemplate(template.id, {
|
||||||
|
pinned: !template.pinned,
|
||||||
|
})
|
||||||
|
if (updated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle template card click - send directly or show confirmation
|
||||||
|
const handleTemplateClick = async (template: Template) => {
|
||||||
|
// If there's unsent content in the input, show confirmation dialog
|
||||||
|
if (currentInput.trim()) {
|
||||||
|
setTemplateToSend(template)
|
||||||
|
setConfirmSendDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No unsent content, send directly
|
||||||
|
await sendTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually send the template
|
||||||
|
const sendTemplate = async (template: Template) => {
|
||||||
|
// Increment click count only when actually sending
|
||||||
|
await incrementClickCount(template.id)
|
||||||
|
if (onSendTemplate) {
|
||||||
|
// Increment run count and update lastUsedAt
|
||||||
|
await incrementRunCount(template.id)
|
||||||
|
// Reload to show updated stats
|
||||||
|
loadTemplates()
|
||||||
|
// Call the send callback
|
||||||
|
onSendTemplate(template)
|
||||||
|
} else {
|
||||||
|
// Fallback: just fill the input if no send callback provided
|
||||||
|
setInput(template.prompt)
|
||||||
|
}
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation dialog - user confirmed to send template
|
||||||
|
const handleConfirmSend = async () => {
|
||||||
|
if (!templateToSend) return
|
||||||
|
await sendTemplate(templateToSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel - close dialog without sending
|
||||||
|
const handleCancelSend = () => {
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export templates to JSON file
|
||||||
|
const handleExport = () => {
|
||||||
|
if (templates.length === 0) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.exportEmpty || "No templates to export",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportData = exportTemplates(templates)
|
||||||
|
const json = JSON.stringify(exportData, null, 2)
|
||||||
|
const blob = new Blob([json], { type: "application/json" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.exportSuccess.replace(
|
||||||
|
"{count}",
|
||||||
|
String(templates.length),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates from JSON file
|
||||||
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
dict.templates.importNoFile || "Please select a JSON file",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Validate import data
|
||||||
|
const validation = validateImportData(data)
|
||||||
|
if (!validation.valid) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
validation.error || "Invalid data",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates with dedup-append strategy
|
||||||
|
const result = await importTemplates(data.templates, templates)
|
||||||
|
|
||||||
|
// Reload template list
|
||||||
|
await loadTemplates()
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.importSuccess
|
||||||
|
.replace("{imported}", String(result.imported))
|
||||||
|
.replace("{skipped}", String(result.skipped)),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state: no templates at all
|
||||||
|
if (!loading && templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.templates.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.templates.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<FileText className="w-8 h-8 text-primary/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
|
{dict.templates.emptyTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||||
|
{dict.templates.emptyDescription}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{dict.templates.createFirst}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template list
|
||||||
|
return (
|
||||||
|
<div className="py-2 px-2 animate-fade-in">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={dict.templates.searchPlaceholder}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.createButton}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={templates.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={dict.templates.exportTemplates}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.exportTemplates}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={dict.templates.importTemplates}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import message */}
|
||||||
|
{importMessage && (
|
||||||
|
<div
|
||||||
|
className={`text-xs px-3 py-2 rounded-lg ${
|
||||||
|
importMessage.type === "success"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading
|
||||||
|
? // Loading skeleton
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${String(i)}`}
|
||||||
|
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
|
<div className="h-3 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: filteredTemplates.length === 0
|
||||||
|
? // Search empty state
|
||||||
|
!loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||||
|
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{dict.templates.searchNoResults}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: filteredTemplates.map((template) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{template.title}
|
||||||
|
</div>
|
||||||
|
{template.pinned && (
|
||||||
|
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions and stats */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
|
{template.runCount > 0
|
||||||
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||||
|
: dict.templates.neverUsed}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTogglePin(template)
|
||||||
|
}}
|
||||||
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
|
template.pinned
|
||||||
|
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
template.pinned
|
||||||
|
? dict.templates
|
||||||
|
.unpin || "Unpin"
|
||||||
|
: dict.templates.pin ||
|
||||||
|
"Pin"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={dict.common.edit}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDuplicate(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={
|
||||||
|
dict.templates.duplicate ||
|
||||||
|
"Duplicate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteClick(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
template={templateToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.deleteTitle ||
|
||||||
|
"Delete this template?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.deleteDescription ||
|
||||||
|
"This will permanently delete this template. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||||
|
>
|
||||||
|
{dict.common.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Confirm Send Dialog - when there's unsent input */}
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmSendDialogOpen}
|
||||||
|
onOpenChange={setConfirmSendDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.confirmSendTitle ||
|
||||||
|
"Replace current input?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.confirmSendDescription ||
|
||||||
|
"You have unsent content in the input. Sending this template will replace it."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelSend}>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
|
{dict.templates.confirmSendButton ||
|
||||||
|
"Send Template"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
components/chat/ToolCallCard.tsx
Normal file
250
components/chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||||
|
import type { Dispatch, SetStateAction } from "react"
|
||||||
|
import { CodeBlock } from "@/components/code-block"
|
||||||
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||||
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||||
|
|
||||||
|
interface ToolCallCardProps {
|
||||||
|
part: ToolPartLike
|
||||||
|
expandedTools: Record<string, boolean>
|
||||||
|
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||||
|
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||||
|
copiedToolCallId: string | null
|
||||||
|
copyFailedToolCallId: string | null
|
||||||
|
dict: {
|
||||||
|
tools: { complete: string }
|
||||||
|
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{operations.map((op, index) => (
|
||||||
|
<div
|
||||||
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
|
op.operation === "delete"
|
||||||
|
? "text-red-600"
|
||||||
|
: op.operation === "add"
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{op.operation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
cell_id: {op.cell_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{op.new_xml && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{op.new_xml}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallCard({
|
||||||
|
part,
|
||||||
|
expandedTools,
|
||||||
|
setExpandedTools,
|
||||||
|
onCopy,
|
||||||
|
copiedToolCallId,
|
||||||
|
copyFailedToolCallId,
|
||||||
|
dict,
|
||||||
|
}: ToolCallCardProps) {
|
||||||
|
const callId = part.toolCallId
|
||||||
|
const { state, input, output } = part
|
||||||
|
// Default to expanded for all states (user can manually collapse if needed)
|
||||||
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
|
const toolName = part.type?.replace("tool-", "")
|
||||||
|
const isCopied = copiedToolCallId === callId
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[callId]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolDisplayName = (name: string) => {
|
||||||
|
switch (name) {
|
||||||
|
case "display_diagram":
|
||||||
|
return "Generate Diagram"
|
||||||
|
case "edit_diagram":
|
||||||
|
return "Edit Diagram"
|
||||||
|
case "get_shape_library":
|
||||||
|
return "Get Shape Library"
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
let textToCopy = ""
|
||||||
|
|
||||||
|
if (input && typeof input === "object") {
|
||||||
|
if (input.xml) {
|
||||||
|
textToCopy = input.xml
|
||||||
|
} else if (input.operations && Array.isArray(input.operations)) {
|
||||||
|
textToCopy = JSON.stringify(input.operations, null, 2)
|
||||||
|
} else if (Object.keys(input).length > 0) {
|
||||||
|
textToCopy = JSON.stringify(input, null, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
typeof output === "string"
|
||||||
|
) {
|
||||||
|
textToCopy = output
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToCopy) {
|
||||||
|
onCopy(callId, textToCopy, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground/80">
|
||||||
|
{getToolDisplayName(toolName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{state === "input-streaming" && (
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "output-available" && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
{dict.tools.complete}
|
||||||
|
</span>
|
||||||
|
{isExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
title={
|
||||||
|
copiedToolCallId === callId
|
||||||
|
? dict.chat.copied
|
||||||
|
: copyFailedToolCallId === callId
|
||||||
|
? dict.chat.failedToCopy
|
||||||
|
: dict.chat.copyResponse
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="w-4 h-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return isTruncated ? (
|
||||||
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
|
Truncated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{input && Object.keys(input).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{input && isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
|
{typeof input === "object" && input.xml ? (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
input.operations &&
|
||||||
|
Array.isArray(input.operations) ? (
|
||||||
|
<OperationsDisplay operations={input.operations} />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
Object.keys(input).length > 0 ? (
|
||||||
|
<CodeBlock
|
||||||
|
code={JSON.stringify(input, null, 2)}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{output &&
|
||||||
|
state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||||
|
: output}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Show get_shape_library output on success */}
|
||||||
|
{output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
state === "output-available" &&
|
||||||
|
isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
Library loaded (
|
||||||
|
{typeof output === "string" ? output.length : 0}{" "}
|
||||||
|
chars)
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||||
|
{typeof output === "string"
|
||||||
|
? output.substring(0, 800) +
|
||||||
|
(output.length > 800 ? "\n..." : "")
|
||||||
|
: String(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
328
components/chat/ValidationCard.tsx
Normal file
328
components/chat/ValidationCard.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
components/chat/types.ts
Normal file
16
components/chat/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: {
|
||||||
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
@@ -1,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>
|
||||||
|
|||||||
16
components/image-with-basepath.tsx
Normal file
16
components/image-with-basepath.tsx
Normal file
@@ -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} />
|
||||||
|
},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,39 +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",
|
|
||||||
edgeone: "tencent-cloud",
|
|
||||||
doubao: "bytedance",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
@@ -61,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)
|
||||||
@@ -89,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(
|
||||||
@@ -101,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)
|
||||||
@@ -159,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",
|
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -189,113 +197,241 @@ 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}
|
||||||
|
value={
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.isDefault && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Models Section */}
|
||||||
|
{userModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<ModelSelectorSeparator />
|
||||||
|
)}
|
||||||
|
<ModelSelectorSectionHeader
|
||||||
|
icon={<User />}
|
||||||
|
label={dict.modelConfig.userModels}
|
||||||
|
/>
|
||||||
|
{Array.from(
|
||||||
|
groupedUserModels.entries(),
|
||||||
|
).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
models: providerModels,
|
||||||
|
},
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`user-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
value={
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !==
|
||||||
|
true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModelSelectorList>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,11 @@ 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 { 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 +57,26 @@ 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: "min" | "sketch"
|
drawioUi: "min" | "sketch"
|
||||||
onToggleDrawioUi: () => 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
|
||||||
}
|
}
|
||||||
|
|
||||||
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,26 +89,49 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
|
vlmValidationEnabled = false,
|
||||||
|
onVlmValidationChange = () => {},
|
||||||
|
onOpenModelConfig,
|
||||||
|
customSystemMessage = "",
|
||||||
|
onCustomSystemMessageChange = () => {},
|
||||||
}: 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
|
// Only fetch if not cached in localStorage
|
||||||
@@ -143,13 +173,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 +205,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 +263,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 +314,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">
|
||||||
@@ -333,25 +449,6 @@ function SettingsContent({
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Close Protection */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.closeProtection}
|
|
||||||
description={dict.settings.closeProtectionDescription}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
id="close-protection"
|
|
||||||
checked={closeProtection}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setCloseProtection(checked)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
|
||||||
checked.toString(),
|
|
||||||
)
|
|
||||||
onCloseProtectionChange?.(checked)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramStyle}
|
label={dict.settings.diagramStyle}
|
||||||
@@ -370,6 +467,194 @@ 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
116
components/url-input-dialog.tsx
Normal file
116
components/url-input-dialog.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,28 +3,36 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } 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 {
|
||||||
|
extractDiagramXML,
|
||||||
|
isRealDiagram,
|
||||||
|
validateAndFixXml,
|
||||||
|
} from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
|
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
|
successMessage?: string,
|
||||||
) => void
|
) => void
|
||||||
saveDiagramToStorage: () => Promise<void>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
|
captureValidationPng: () => Promise<string | null>
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -41,71 +49,37 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{ svg: string; xml: string }[]
|
{ svg: string; xml: string }[]
|
||||||
>([])
|
>([])
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
|
// Resolver for PNG export (used for VLM validation)
|
||||||
|
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
// Track if diagram has been restored from localStorage
|
// Track latest chartXML for restoration after remount
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
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
|
// Keep chartXMLRef in sync with state for restoration after remount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
chartXMLRef.current = chartXML
|
||||||
if (!isDrawioReady) {
|
}, [chartXML])
|
||||||
hasDiagramRestoredRef.current = false
|
|
||||||
setCanSaveDiagram(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hasDiagramRestoredRef.current) return
|
|
||||||
hasDiagramRestoredRef.current = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const savedDiagramXml = localStorage.getItem(
|
|
||||||
STORAGE_DIAGRAM_XML_KEY,
|
|
||||||
)
|
|
||||||
if (savedDiagramXml) {
|
|
||||||
// Skip validation for trusted saved diagrams
|
|
||||||
loadDiagram(savedDiagramXml, true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to restore diagram from localStorage:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow saving after restore is complete
|
|
||||||
setTimeout(() => {
|
|
||||||
setCanSaveDiagram(true)
|
|
||||||
}, 500)
|
|
||||||
}, [isDrawioReady])
|
|
||||||
|
|
||||||
// Save diagram XML to localStorage whenever it changes (debounced)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canSaveDiagram) return
|
|
||||||
if (!chartXML || chartXML.length <= 300) return
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId)
|
|
||||||
}, [chartXML, canSaveDiagram])
|
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
@@ -132,27 +106,63 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current diagram to localStorage (used before theme/UI changes)
|
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||||
const saveDiagramToStorage = async (): Promise<void> => {
|
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return
|
if (!drawioRef.current) return null
|
||||||
|
// Don't export if diagram is empty
|
||||||
|
if (!isRealDiagram(chartXML)) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentXml = await Promise.race([
|
const svgData = await Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 2000),
|
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Only save if diagram has meaningful content (not empty template)
|
// Update latestSvg so it's available for future saves
|
||||||
if (currentXml && currentXml.length > 300) {
|
if (svgData?.includes("<svg")) {
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
setLatestSvg(svgData)
|
||||||
|
return svgData
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return null
|
||||||
console.error("Failed to save diagram to storage:", error)
|
} catch {
|
||||||
|
// Timeout is expected occasionally - don't log as error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture current diagram as PNG for VLM validation
|
||||||
|
const captureValidationPng = async (): Promise<string | null> => {
|
||||||
|
if (!drawioRef.current) return null
|
||||||
|
// Don't export if diagram is empty
|
||||||
|
if (!isRealDiagram(chartXML)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pngData = await Promise.race([
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
pngResolverRef.current = resolve
|
||||||
|
drawioRef.current?.exportDiagram({ format: "png" })
|
||||||
|
}),
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error("PNG export timeout")),
|
||||||
|
5000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
// PNG data should be a base64 data URL
|
||||||
|
if (pngData?.startsWith("data:image/png")) {
|
||||||
|
return pngData
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
// Timeout is expected occasionally - don't log as error
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +205,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: any) => {
|
const handleDiagramExport = (data: any) => {
|
||||||
|
// Handle PNG export for VLM validation
|
||||||
|
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||||
|
pngResolverRef.current(data.data)
|
||||||
|
pngResolverRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Handle save to file if requested (process raw data before extraction)
|
||||||
if (saveResolverRef.current.resolver) {
|
if (saveResolverRef.current.resolver) {
|
||||||
const format = saveResolverRef.current.format
|
const format = saveResolverRef.current.format
|
||||||
@@ -202,7 +219,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
saveResolverRef.current = { resolver: null, format: null }
|
saveResolverRef.current = { resolver: null, format: null }
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||||
if (format === "png" || format === "svg") {
|
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||||
|
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +253,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||||
|
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||||
|
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChartXML(data.xml)
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -247,6 +275,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,7 +283,8 @@ 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
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
@@ -273,16 +303,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = xmlContent
|
fileContent = xmlContent
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
|
|
||||||
// Save to localStorage when user manually saves
|
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
|
} else if (format === "xmlsvg") {
|
||||||
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".drawio.svg"
|
||||||
} else {
|
} else {
|
||||||
// SVG format
|
// SVG format (view-only)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
@@ -311,6 +343,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
// Show success toast after download is initiated
|
||||||
|
if (successMessage) {
|
||||||
|
toast.success(successMessage, {
|
||||||
|
position: "bottom-left",
|
||||||
|
duration: 2500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
@@ -346,15 +386,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXML,
|
chartXML,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
|
setDiagramHistory,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
saveDiagramToStorage,
|
getThumbnailSvg,
|
||||||
|
captureValidationPng,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
# environment:
|
||||||
# For subdirectory deployment, uncomment and set your path:
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
78
docs/cn/FAQ.md
Normal file
78
docs/cn/FAQ.md
Normal file
@@ -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
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
一个集成了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 模型!
|
> 注:感谢 <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
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目录](#目录)
|
- [目录](#目录)
|
||||||
- [示例](#示例)
|
- [示例](#示例)
|
||||||
- [功能特性](#功能特性)
|
- [功能特性](#功能特性)
|
||||||
- [MCP服务器(预览)](#mcp服务器预览)
|
- [MCP服务器](#mcp服务器)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
@@ -37,11 +39,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [安装](#安装)
|
- [安装](#安装)
|
||||||
- [部署](#部署)
|
- [部署](#部署)
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||||
- [部署到Vercel(推荐)](#部署到vercel推荐)
|
- [部署到Vercel](#部署到vercel)
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||||
- [多提供商支持](#多提供商支持)
|
- [多提供商支持](#多提供商支持)
|
||||||
- [工作原理](#工作原理)
|
- [工作原理](#工作原理)
|
||||||
- [支持与联系](#支持与联系)
|
- [支持与联系](#支持与联系)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
- [Star历史](#star历史)
|
- [Star历史](#star历史)
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
@@ -95,9 +98,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
## MCP服务器(预览)
|
## MCP服务器
|
||||||
|
|
||||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ npm run dev
|
|||||||
|
|
||||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
||||||
|
|
||||||
### 部署到Vercel(推荐)
|
### 部署到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)
|
||||||
|
|
||||||
@@ -194,16 +195,18 @@ npm run dev
|
|||||||
|
|
||||||
## 多提供商支持
|
## 多提供商支持
|
||||||
|
|
||||||
- [字节跳动豆包](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)
|
- [字节跳动豆包](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
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -211,6 +214,10 @@ npm run dev
|
|||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||||
|
|
||||||
|
### 服务端多模型配置
|
||||||
|
|
||||||
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
@@ -229,7 +236,7 @@ npm run dev
|
|||||||
|
|
||||||
## 支持与联系
|
## 支持与联系
|
||||||
|
|
||||||
**特别感谢[字节跳动豆包](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://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)来帮助我托管在线演示站点!
|
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||||
|
|
||||||
@@ -237,6 +244,10 @@ npm run dev
|
|||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
- 邮箱:me[at]jiang.jp
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
||||||
|
|
||||||
## Star历史
|
## Star历史
|
||||||
|
|
||||||
[](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)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
### 豆包 (字节跳动火山引擎)
|
||||||
|
|
||||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
> **免费 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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -152,6 +152,19 @@ AI_PROVIDER=ollama
|
|||||||
AI_MODEL=llama3.2
|
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:
|
可选的自定义 URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -194,6 +207,85 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
从 [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-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
可选配置:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -201,9 +293,66 @@ AI_MODEL=openai/gpt-4o
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 服务端多模型配置
|
||||||
|
|
||||||
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 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` 指定路径)。
|
||||||
|
|
||||||
|
### 配置示例
|
||||||
|
|
||||||
|
```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)的长文本。
|
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
||||||
|
|||||||
78
docs/en/FAQ.md
Normal file
78
docs/en/FAQ.md
Normal file
@@ -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
|
||||||
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### 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!
|
> **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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -33,6 +33,21 @@ Optional custom endpoint:
|
|||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
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
|
### OpenAI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -158,6 +173,19 @@ Optional custom URL:
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -194,6 +222,85 @@ Model format uses `provider/model` syntax:
|
|||||||
|
|
||||||
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
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-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Auto-Detection
|
## 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 only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
@@ -201,9 +308,66 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
### 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
|
## Model Capability Requirements
|
||||||
|
|
||||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ cp env.example .env
|
|||||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
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.
|
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.
|
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
|
||||||
|
|||||||
78
docs/ja/FAQ.md
Normal file
78
docs/ja/FAQ.md
Normal file
@@ -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
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
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,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目次](#目次)
|
- [目次](#目次)
|
||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
- [MCPサーバー](#mcpサーバー)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
@@ -37,11 +37,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [インストール](#インストール)
|
- [インストール](#インストール)
|
||||||
- [デプロイ](#デプロイ)
|
- [デプロイ](#デプロイ)
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||||
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
|
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||||
|
- [よくある質問](#よくある質問)
|
||||||
- [スター履歴](#スター履歴)
|
- [スター履歴](#スター履歴)
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
@@ -95,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を使用できます。
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@ npm run dev
|
|||||||
|
|
||||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
||||||
|
|
||||||
### Vercelへのデプロイ(推奨)
|
### 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)
|
||||||
|
|
||||||
@@ -195,16 +194,18 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
|
|
||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- [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
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -212,6 +213,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||||
|
|
||||||
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
@@ -230,7 +235,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
## サポート&お問い合わせ
|
## サポート&お問い合わせ
|
||||||
|
|
||||||
**デモサイトの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)をご検討ください!
|
||||||
|
|
||||||
@@ -238,6 +243,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
- メール: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)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
> **無料トークン**: [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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -158,6 +158,19 @@ AI_MODEL=llama3.2
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
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
|
||||||
|
|
||||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
||||||
@@ -194,6 +207,85 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
[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-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
オプション設定:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -201,9 +293,66 @@ AI_MODEL=openai/gpt-4o
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
|
管理者は、ユーザーが個人の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` でパスを指定)。
|
||||||
|
|
||||||
|
### 設定例
|
||||||
|
|
||||||
|
```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)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
||||||
|
|||||||
367
docs/shape-libraries/material_design.md
Normal file
367
docs/shape-libraries/material_design.md
Normal file
@@ -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)
|
||||||
@@ -99,7 +99,7 @@ function handleOptionsRequest(): Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onRequest({ request, env }: any) {
|
export async function onRequest({ request, env: _env }: any) {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return handleOptionsRequest()
|
return handleOptionsRequest()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
37
electron/electron.d.ts
vendored
37
electron/electron.d.ts
vendored
@@ -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 */
|
||||||
@@ -45,6 +64,16 @@ declare global {
|
|||||||
openFile: () => Promise<string | null>
|
openFile: () => Promise<string | null>
|
||||||
/** Save data to file via save dialog */
|
/** Save data to file via save dialog */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
saveFile: (data: string) => Promise<boolean>
|
||||||
|
/** Get proxy configuration */
|
||||||
|
getProxy: () => Promise<ProxyConfig>
|
||||||
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
|
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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -71,4 +100,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ConfigPreset, ApplyPresetResult }
|
export type {
|
||||||
|
ConfigPreset,
|
||||||
|
ApplyPresetResult,
|
||||||
|
ProxyConfig,
|
||||||
|
SetProxyResult,
|
||||||
|
SetUserLocaleResult,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,22 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template
|
* 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 +49,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 +60,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 +88,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 +165,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 +181,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()
|
||||||
|
|
||||||
@@ -216,11 +223,11 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
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 +236,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)
|
||||||
|
|||||||
@@ -137,6 +137,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"
|
||||||
@@ -161,6 +162,7 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +183,7 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,10 +354,14 @@ 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" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,3 +465,23 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getCurrentPresetEnv } 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, getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
loadEnvFile()
|
||||||
|
|
||||||
|
// Apply proxy settings from saved config
|
||||||
|
applyProxyToEnv()
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
// Apply saved preset environment variables (overrides .env)
|
||||||
const presetEnv = getCurrentPresetEnv()
|
const presetEnv = getCurrentPresetEnv()
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
for (const [key, value] of Object.entries(presetEnv)) {
|
||||||
@@ -90,7 +94,8 @@ if (!gotTheLock) {
|
|||||||
if (
|
if (
|
||||||
url.includes("diagrams.net") ||
|
url.includes("diagrams.net") ||
|
||||||
url.includes("draw.io") ||
|
url.includes("draw.io") ||
|
||||||
url.startsWith("http://localhost")
|
url.startsWith("http://localhost") ||
|
||||||
|
url.startsWith("http://127.0.0.1")
|
||||||
) {
|
) {
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||||
|
import { rebuildAppMenu } from "./app-menu"
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
applyPresetToEnv,
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
@@ -7,10 +8,18 @@ import {
|
|||||||
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 {
|
||||||
|
applyProxyToEnv,
|
||||||
|
getProxyConfig,
|
||||||
|
type ProxyConfig,
|
||||||
|
saveProxyConfig,
|
||||||
|
} from "./proxy-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -209,4 +218,68 @@ export function registerIpcHandlers(): void {
|
|||||||
return setCurrentPreset(id)
|
return setCurrentPreset(id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ==================== Proxy Settings ====================
|
||||||
|
|
||||||
|
ipcMain.handle("get-proxy", () => {
|
||||||
|
return getProxyConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||||
|
try {
|
||||||
|
// Save config to file
|
||||||
|
saveProxyConfig(config)
|
||||||
|
|
||||||
|
// Apply to current process environment
|
||||||
|
applyProxyToEnv()
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// 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 ====================
|
||||||
|
|
||||||
|
ipcMain.handle("get-user-locale", () => {
|
||||||
|
return getUserLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
211
electron/main/menu-i18n.ts
Normal file
211
electron/main/menu-i18n.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -68,7 +68,9 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,6 +87,13 @@ 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, [], {
|
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||||
@@ -114,13 +123,41 @@ 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) {
|
if (serverProcess) {
|
||||||
console.log("Stopping Next.js server...")
|
console.log("Stopping Next.js server...")
|
||||||
|
|
||||||
|
// Create a promise that resolves when the process exits
|
||||||
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
|
const proc = serverProcess
|
||||||
|
if (!proc) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExit = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.once("exit", onExit)
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
proc.removeListener("exit", onExit)
|
||||||
|
resolve()
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
serverProcess.kill()
|
serverProcess.kill()
|
||||||
serverProcess = null
|
serverProcess = null
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
await exitPromise
|
||||||
|
|
||||||
|
// Additional wait for OS to release port
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +187,8 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
export async function restartNextServer(): Promise<string> {
|
export async function restartNextServer(): Promise<string> {
|
||||||
console.log("Restarting Next.js server...")
|
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()
|
||||||
|
|||||||
@@ -9,9 +9,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,
|
||||||
}
|
}
|
||||||
@@ -27,7 +29,10 @@ let allocatedPort: number | null = null
|
|||||||
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 +44,12 @@ 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 fixed port (13370) to preserve localStorage
|
||||||
* - 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,7 +69,16 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try preferred port first
|
// In production, try legacy port first to preserve existing users' localStorage
|
||||||
|
if (!isDev) {
|
||||||
|
const legacyPort = PORT_CONFIG.legacyProduction
|
||||||
|
if (await isPortAvailable(legacyPort)) {
|
||||||
|
allocatedPort = legacyPort
|
||||||
|
return legacyPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try preferred port
|
||||||
if (await isPortAvailable(preferredPort)) {
|
if (await isPortAvailable(preferredPort)) {
|
||||||
allocatedPort = preferredPort
|
allocatedPort = preferredPort
|
||||||
return preferredPort
|
return preferredPort
|
||||||
@@ -84,9 +98,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 +141,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}`
|
||||||
}
|
}
|
||||||
|
|||||||
75
electron/main/proxy-manager.ts
Normal file
75
electron/main/proxy-manager.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
export function loadProxyConfig(): ProxyConfig {
|
||||||
|
try {
|
||||||
|
const configPath = getConfigPath()
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const data = fs.readFileSync(configPath, "utf-8")
|
||||||
|
return JSON.parse(data) as ProxyConfig
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load proxy config:", error)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save proxy configuration to JSON file
|
||||||
|
*/
|
||||||
|
export function saveProxyConfig(config: ProxyConfig): void {
|
||||||
|
try {
|
||||||
|
const configPath = getConfigPath()
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
|
} 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()
|
||||||
|
|
||||||
|
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 || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,11 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
|
|
||||||
// Handle page title updates
|
// Handle page title updates
|
||||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||||
if (title && !title.includes("localhost")) {
|
if (
|
||||||
|
title &&
|
||||||
|
!title.includes("localhost") &&
|
||||||
|
!title.includes("127.0.0.1")
|
||||||
|
) {
|
||||||
mainWindow?.setTitle(title)
|
mainWindow?.setTitle(title)
|
||||||
} else {
|
} else {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -21,4 +21,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
// File operations
|
// File operations
|
||||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||||
|
|
||||||
|
// Proxy settings
|
||||||
|
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||||
|
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||||
|
ipcRenderer.invoke("set-proxy", config),
|
||||||
|
|
||||||
|
// User locale settings
|
||||||
|
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||||
|
setUserLocale: (locale: string) =>
|
||||||
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
<option value="siliconflow">SiliconFlow</option>
|
||||||
|
<option value="modelscope">ModelScope</option>
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ function getProviderLabel(provider) {
|
|||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
siliconflow: "SiliconFlow",
|
siliconflow: "SiliconFlow",
|
||||||
|
modelscope: "ModelScope",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
}
|
}
|
||||||
return labels[provider] || provider
|
return labels[provider] || provider
|
||||||
|
|||||||
66
env.example
66
env.example
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -40,6 +40,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
||||||
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
||||||
|
|
||||||
|
# Google Vertex AI Configuration (Enterprise GCP)
|
||||||
|
# For enterprise users needing data residency, VPC Service Controls, or GCP integration
|
||||||
|
# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key
|
||||||
|
# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL
|
||||||
|
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
||||||
|
# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
|
# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high)
|
||||||
|
|
||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
# Configure endpoint using ONE of these methods:
|
# Configure endpoint using ONE of these methods:
|
||||||
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
||||||
@@ -51,8 +59,9 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
||||||
# AZURE_REASONING_SUMMARY=detailed
|
# AZURE_REASONING_SUMMARY=detailed
|
||||||
|
|
||||||
# Ollama (Local) Configuration
|
# Ollama Configuration (Local or Cloud)
|
||||||
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
||||||
|
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
||||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||||
|
|
||||||
# OpenRouter Configuration
|
# OpenRouter Configuration
|
||||||
@@ -72,6 +81,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SGLANG_API_KEY=your-sglang-api-key
|
# SGLANG_API_KEY=your-sglang-api-key
|
||||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||||
|
|
||||||
|
# ModelScope Configuration
|
||||||
|
# MODELSCOPE_API_KEY=ms-...
|
||||||
|
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# ByteDance Doubao Configuration (via Volcengine)
|
# ByteDance Doubao Configuration (via Volcengine)
|
||||||
# DOUBAO_API_KEY=your-doubao-api-key
|
# DOUBAO_API_KEY=your-doubao-api-key
|
||||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||||
@@ -89,6 +102,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
||||||
|
|
||||||
|
# Optional server-side multi-model configuration
|
||||||
|
# If set, points to a JSON file with server-provided models (see README for schema).
|
||||||
|
# Default: ./ai-models.json in project root
|
||||||
|
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
|
||||||
|
|
||||||
# Temperature (Optional)
|
# Temperature (Optional)
|
||||||
# Controls randomness in AI responses. Lower = more deterministic.
|
# Controls randomness in AI responses. Lower = more deterministic.
|
||||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||||
@@ -112,3 +130,45 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
# ENABLE_PDF_INPUT=true
|
# ENABLE_PDF_INPUT=true
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
|
# Security Settings (Optional)
|
||||||
|
# Allow private/internal URLs for reverse proxy setups (default: true)
|
||||||
|
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||||
|
# ALLOW_PRIVATE_URLS=false
|
||||||
|
|
||||||
|
# Self-hosted deployment (Optional)
|
||||||
|
# Self-hosted users may implement custom quota-management solutions,
|
||||||
|
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
||||||
|
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||||
|
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||||
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
|
# Minimax Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
||||||
|
# MINIMAX_API_KEY=your_minimax_api_key
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland)
|
||||||
|
|
||||||
|
# GLM Configuration (Optional)
|
||||||
|
# Get your API key from: https://open.bigmodel.cn/dev/api
|
||||||
|
# GLM_API_KEY=your_glm_api_key
|
||||||
|
# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default
|
||||||
|
|
||||||
|
# Qwen Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.aliyun.com/product/bailian
|
||||||
|
# QWEN_API_KEY=your_qwen_api_key
|
||||||
|
# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default
|
||||||
|
|
||||||
|
# Kimi Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.moonshot.cn/
|
||||||
|
# KIMI_API_KEY=your_kimi_api_key
|
||||||
|
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default
|
||||||
|
|
||||||
|
# Qiniu Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.qiniu.com/ai/models
|
||||||
|
# QINIU_API_KEY=your_qiniu_api_key
|
||||||
|
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
|
||||||
|
|
||||||
|
# Novita AI Configuration (Optional)
|
||||||
|
# Get your API key from: https://novita.ai/dashboard/key
|
||||||
|
# NOVITA_API_KEY=your_novita_api_key
|
||||||
|
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
|
import { useRef } from "react"
|
||||||
|
import type { DiagramOperation } from "@/components/chat/types"
|
||||||
|
import type {
|
||||||
|
ValidationState,
|
||||||
|
ValidationStatus,
|
||||||
|
} from "@/components/chat/ValidationCard"
|
||||||
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
@@ -29,11 +37,13 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
|||||||
|
|
||||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||||
|
|
||||||
interface DiagramOperation {
|
const MAX_VALIDATION_RETRIES = 3
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
// Type for the validation function passed from useValidateDiagram hook
|
||||||
new_xml?: string
|
type ValidateDiagramFn = (
|
||||||
}
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
) => Promise<ValidationResult>
|
||||||
|
|
||||||
interface UseDiagramToolHandlersParams {
|
interface UseDiagramToolHandlersParams {
|
||||||
partialXmlRef: MutableRefObject<string>
|
partialXmlRef: MutableRefObject<string>
|
||||||
@@ -42,6 +52,14 @@ interface UseDiagramToolHandlersParams {
|
|||||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
captureValidationPng?: () => Promise<string | null>
|
||||||
|
validateDiagram?: ValidateDiagramFn
|
||||||
|
enableVlmValidation?: boolean
|
||||||
|
sessionId?: string
|
||||||
|
onValidationStateChange?: (
|
||||||
|
toolCallId: string,
|
||||||
|
state: ValidationState,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +76,34 @@ export function useDiagramToolHandlers({
|
|||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
onExport,
|
onExport,
|
||||||
|
captureValidationPng,
|
||||||
|
validateDiagram,
|
||||||
|
enableVlmValidation = true,
|
||||||
|
sessionId,
|
||||||
|
onValidationStateChange,
|
||||||
}: UseDiagramToolHandlersParams) {
|
}: UseDiagramToolHandlersParams) {
|
||||||
|
// Track validation retry count per tool call
|
||||||
|
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
|
||||||
|
|
||||||
|
// Helper to update validation state
|
||||||
|
const updateValidationState = (
|
||||||
|
toolCallId: string,
|
||||||
|
status: ValidationStatus,
|
||||||
|
options?: {
|
||||||
|
attempt?: number
|
||||||
|
maxAttempts?: number
|
||||||
|
result?: ValidationResult
|
||||||
|
error?: string
|
||||||
|
imageData?: string
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (onValidationStateChange) {
|
||||||
|
onValidationStateChange(toolCallId, {
|
||||||
|
status,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
const handleToolCall = async (
|
const handleToolCall = async (
|
||||||
{ toolCall }: { toolCall: ToolCall },
|
{ toolCall }: { toolCall: ToolCall },
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
@@ -160,7 +205,159 @@ ${finalXml}
|
|||||||
// Success - diagram will be rendered by chat-message-display
|
// Success - diagram will be rendered by chat-message-display
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLM validation after successful display
|
||||||
|
if (
|
||||||
|
enableVlmValidation &&
|
||||||
|
captureValidationPng &&
|
||||||
|
validateDiagram
|
||||||
|
) {
|
||||||
|
let capturedPngData: string | null = null
|
||||||
|
try {
|
||||||
|
// Notify UI that we're starting capture
|
||||||
|
updateValidationState(toolCall.toolCallId, "capturing")
|
||||||
|
|
||||||
|
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||||
|
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
capturedPngData = await captureValidationPng()
|
||||||
|
if (capturedPngData) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Captured PNG for validation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryCount =
|
||||||
|
validationRetryCountRef.current.get(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
) || 0
|
||||||
|
|
||||||
|
// Notify UI that we're validating (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"validating",
|
||||||
|
{
|
||||||
|
attempt: retryCount + 1,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await validateDiagram(
|
||||||
|
capturedPngData,
|
||||||
|
sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||||
|
validationRetryCountRef.current.set(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
retryCount + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const feedback =
|
||||||
|
formatValidationFeedback(result)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||||
|
result.issues,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI of validation failure (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"failed",
|
||||||
|
{
|
||||||
|
attempt: retryCount + 1,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
result,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Max retries reached - accept the diagram with warning
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notify UI that we're accepting with issues (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"skipped",
|
||||||
|
{ result, imageData: capturedPngData },
|
||||||
|
)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validation passed - clean up retry count
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Validation passed!",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI of success (include the image)
|
||||||
|
// Use "success_with_warnings" if valid but has issues
|
||||||
|
const hasWarnings = result.issues.length > 0
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
hasWarnings
|
||||||
|
? "success_with_warnings"
|
||||||
|
: "success",
|
||||||
|
{ result, imageData: capturedPngData },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PNG capture failed - skip validation
|
||||||
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// VLM validation error - log but don't block the user
|
||||||
|
console.warn(
|
||||||
|
"[display_diagram] VLM validation error:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
updateValidationState(toolCall.toolCallId, "error", {
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Validation failed",
|
||||||
|
imageData: capturedPngData || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Adding tool output with state: output-available",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import {
|
import {
|
||||||
createEmptyConfig,
|
createEmptyConfig,
|
||||||
@@ -11,7 +13,6 @@ import {
|
|||||||
flattenModels,
|
flattenModels,
|
||||||
type ModelConfig,
|
type ModelConfig,
|
||||||
type MultiModelConfig,
|
type MultiModelConfig,
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
@@ -133,14 +134,56 @@ export interface UseModelConfigReturn {
|
|||||||
export function useModelConfig(): UseModelConfigReturn {
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
|
||||||
|
const [serverLoaded, setServerLoaded] = useState(false)
|
||||||
|
|
||||||
// Load config on mount
|
// Load client config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadConfig()
|
const loaded = loadConfig()
|
||||||
setConfig(loaded)
|
setConfig(loaded)
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load server models on mount (if any)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
fetch(getApiEndpoint("/api/server-models"))
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(
|
||||||
|
"Failed to load server models:",
|
||||||
|
res.status,
|
||||||
|
res.statusText,
|
||||||
|
)
|
||||||
|
throw new Error(`Request failed with status ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
const raw: FlattenedServerModel[] = data?.models || []
|
||||||
|
setServerModels(raw)
|
||||||
|
setServerLoaded(true)
|
||||||
|
|
||||||
|
// Auto-select default server model if no model is currently selected
|
||||||
|
setConfig((prev) => {
|
||||||
|
if (!prev.selectedModelId && raw.length > 0) {
|
||||||
|
const defaultModel = raw.find((m) => m.isDefault)
|
||||||
|
if (defaultModel) {
|
||||||
|
return { ...prev, selectedModelId: defaultModel.id }
|
||||||
|
}
|
||||||
|
// If no default marked, use first server model
|
||||||
|
return { ...prev, selectedModelId: raw[0].id }
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error while loading server models:", error)
|
||||||
|
setServerLoaded(true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Save config whenever it changes (after initial load)
|
// Save config whenever it changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
@@ -149,9 +192,33 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}, [config, isLoaded])
|
}, [config, isLoaded])
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const models = flattenModels(config)
|
const userModels = flattenModels(config)
|
||||||
|
|
||||||
|
const models: FlattenedModel[] = [
|
||||||
|
// Server models (read-only, credentials from env)
|
||||||
|
...serverModels.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
modelId: m.modelId,
|
||||||
|
provider: m.provider,
|
||||||
|
providerLabel: `Server · ${m.providerLabel}`,
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: undefined,
|
||||||
|
awsAccessKeyId: undefined,
|
||||||
|
awsSecretAccessKey: undefined,
|
||||||
|
awsRegion: undefined,
|
||||||
|
awsSessionToken: undefined,
|
||||||
|
validated: true,
|
||||||
|
source: "server" as const,
|
||||||
|
isDefault: m.isDefault,
|
||||||
|
apiKeyEnv: m.apiKeyEnv,
|
||||||
|
baseUrlEnv: m.baseUrlEnv,
|
||||||
|
})),
|
||||||
|
// User models from local configuration
|
||||||
|
...userModels,
|
||||||
|
]
|
||||||
|
|
||||||
const selectedModel = config.selectedModelId
|
const selectedModel = config.selectedModelId
|
||||||
? findModelById(config, config.selectedModelId)
|
? models.find((m) => m.id === config.selectedModelId)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -283,7 +350,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoaded,
|
isLoaded: isLoaded && serverLoaded,
|
||||||
models,
|
models,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
@@ -315,6 +382,10 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: string
|
awsSecretAccessKey: string
|
||||||
awsRegion: string
|
awsRegion: string
|
||||||
awsSessionToken: string
|
awsSessionToken: string
|
||||||
|
// Selected model ID (for server model lookup)
|
||||||
|
selectedModelId: string
|
||||||
|
// Vertex AI credentials (Express Mode)
|
||||||
|
vertexApiKey: string
|
||||||
} {
|
} {
|
||||||
const empty = {
|
const empty = {
|
||||||
accessCode: "",
|
accessCode: "",
|
||||||
@@ -326,6 +397,8 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
|
selectedModelId: "",
|
||||||
|
vertexApiKey: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
if (typeof window === "undefined") return empty
|
||||||
@@ -348,6 +421,8 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
|
selectedModelId: "",
|
||||||
|
vertexApiKey: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,12 +433,32 @@ export function getSelectedAIConfig(): {
|
|||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No selected model = use server default
|
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
|
||||||
if (!config.selectedModelId) {
|
if (!config.selectedModelId) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find selected model
|
// Server-side model selection (id = "server:<name-slug>:<modelId>")
|
||||||
|
// Provider is resolved server-side via findServerModelById()
|
||||||
|
if (config.selectedModelId.startsWith("server:")) {
|
||||||
|
const parts = config.selectedModelId.split(":")
|
||||||
|
const nameSlug = parts[1] || ""
|
||||||
|
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
|
||||||
|
|
||||||
|
return {
|
||||||
|
...empty,
|
||||||
|
accessCode,
|
||||||
|
// Note: nameSlug is NOT the provider, but we send it for backwards compat
|
||||||
|
// Server uses selectedModelId to lookup the actual provider
|
||||||
|
aiProvider: nameSlug,
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiModel: modelId,
|
||||||
|
selectedModelId: config.selectedModelId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find selected user-defined model
|
||||||
const model = findModelById(config, config.selectedModelId)
|
const model = findModelById(config, config.selectedModelId)
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
@@ -380,5 +475,8 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||||
awsRegion: model.awsRegion || "",
|
awsRegion: model.awsRegion || "",
|
||||||
awsSessionToken: model.awsSessionToken || "",
|
awsSessionToken: model.awsSessionToken || "",
|
||||||
|
selectedModelId: config.selectedModelId || "",
|
||||||
|
// Vertex AI credentials (Express Mode)
|
||||||
|
vertexApiKey: model.vertexApiKey || "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
322
hooks/use-session-manager.ts
Normal file
322
hooks/use-session-manager.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ChatSession,
|
||||||
|
createEmptySession,
|
||||||
|
deleteSession as deleteSessionFromDB,
|
||||||
|
enforceSessionLimit,
|
||||||
|
extractTitle,
|
||||||
|
getAllSessionMetadata,
|
||||||
|
getSession,
|
||||||
|
isIndexedDBAvailable,
|
||||||
|
migrateFromLocalStorage,
|
||||||
|
type SessionMetadata,
|
||||||
|
type StoredMessage,
|
||||||
|
saveSession,
|
||||||
|
} from "@/lib/session-storage"
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
messages: StoredMessage[]
|
||||||
|
xmlSnapshots: [number, string][]
|
||||||
|
diagramXml: string
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
diagramHistory?: { svg: string; xml: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSessionManagerReturn {
|
||||||
|
// State
|
||||||
|
sessions: SessionMetadata[]
|
||||||
|
currentSessionId: string | null
|
||||||
|
currentSession: ChatSession | null
|
||||||
|
isLoading: boolean
|
||||||
|
isAvailable: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
switchSession: (id: string) => Promise<SessionData | null>
|
||||||
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||||
|
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||||
|
saveCurrentSession: (
|
||||||
|
data: SessionData,
|
||||||
|
forSessionId?: string | null,
|
||||||
|
) => Promise<void>
|
||||||
|
refreshSessions: () => Promise<void>
|
||||||
|
clearCurrentSession: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSessionManagerOptions {
|
||||||
|
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
||||||
|
initialSessionId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionManager(
|
||||||
|
options: UseSessionManagerOptions = {},
|
||||||
|
): UseSessionManagerReturn {
|
||||||
|
const { initialSessionId } = options
|
||||||
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isAvailable, setIsAvailable] = useState(false)
|
||||||
|
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||||
|
const urlChangeSequenceRef = useRef(0)
|
||||||
|
|
||||||
|
// Load sessions list
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
const metadata = await getAllSessionMetadata()
|
||||||
|
setSessions(metadata)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh sessions:", error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializedRef.current) return
|
||||||
|
isInitializedRef.current = true
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
if (!isIndexedDBAvailable()) {
|
||||||
|
setIsAvailable(false)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAvailable(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run migration first (one-time conversion from localStorage)
|
||||||
|
await migrateFromLocalStorage()
|
||||||
|
|
||||||
|
// Load sessions list
|
||||||
|
const metadata = await getAllSessionMetadata()
|
||||||
|
setSessions(metadata)
|
||||||
|
|
||||||
|
// Only load a session if initialSessionId is provided (from URL param)
|
||||||
|
if (initialSessionId) {
|
||||||
|
const session = await getSession(initialSessionId)
|
||||||
|
if (session) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
}
|
||||||
|
// If session not found, stay in blank state (URL has invalid session ID)
|
||||||
|
}
|
||||||
|
// If no initialSessionId, start with blank state (no auto-restore)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize session manager:", error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
}, [initialSessionId])
|
||||||
|
|
||||||
|
// Handle URL session ID changes after initialization
|
||||||
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||||
|
// when clearCurrentSession() is called before URL updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitializedRef.current) return // Wait for initial load
|
||||||
|
if (!isAvailable) return
|
||||||
|
|
||||||
|
// Increment sequence to invalidate any pending async operations
|
||||||
|
urlChangeSequenceRef.current++
|
||||||
|
const currentSequence = urlChangeSequenceRef.current
|
||||||
|
|
||||||
|
async function handleSessionIdChange() {
|
||||||
|
if (initialSessionId) {
|
||||||
|
// URL has session ID - load it
|
||||||
|
const session = await getSession(initialSessionId)
|
||||||
|
|
||||||
|
// Check if this request is still the latest (sequence guard)
|
||||||
|
// If not, a newer URL change happened while we were loading
|
||||||
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// Only update if the session is different from current
|
||||||
|
setCurrentSessionId((current) => {
|
||||||
|
if (current !== session.id) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Removed: else clause that clears session
|
||||||
|
// Clearing is now handled explicitly by clearCurrentSession()
|
||||||
|
// This prevents race conditions when URL update is async
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSessionIdChange()
|
||||||
|
}, [initialSessionId, isAvailable])
|
||||||
|
|
||||||
|
// Refresh sessions on window focus (multi-tab sync)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
refreshSessions()
|
||||||
|
}
|
||||||
|
window.addEventListener("focus", handleFocus)
|
||||||
|
return () => window.removeEventListener("focus", handleFocus)
|
||||||
|
}, [refreshSessions])
|
||||||
|
|
||||||
|
// Switch to a different session
|
||||||
|
const switchSession = useCallback(
|
||||||
|
async (id: string): Promise<SessionData | null> => {
|
||||||
|
if (id === currentSessionId) return null
|
||||||
|
|
||||||
|
// Save current session first if it has messages
|
||||||
|
if (currentSession && currentSession.messages.length > 0) {
|
||||||
|
await saveSession(currentSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the target session
|
||||||
|
const session = await getSession(id)
|
||||||
|
if (!session) {
|
||||||
|
console.error("Session not found:", id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: session.messages,
|
||||||
|
xmlSnapshots: session.xmlSnapshots,
|
||||||
|
diagramXml: session.diagramXml,
|
||||||
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||||
|
diagramHistory: session.diagramHistory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentSessionId, currentSession],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete a session
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
||||||
|
const wasCurrentSession = id === currentSessionId
|
||||||
|
await deleteSessionFromDB(id)
|
||||||
|
|
||||||
|
// If deleting current session, clear state (caller will show new empty session)
|
||||||
|
if (wasCurrentSession) {
|
||||||
|
setCurrentSession(null)
|
||||||
|
setCurrentSessionId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessions()
|
||||||
|
|
||||||
|
return { wasCurrentSession }
|
||||||
|
},
|
||||||
|
[currentSessionId, refreshSessions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save current session data (debounced externally by caller)
|
||||||
|
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||||
|
const saveCurrentSession = useCallback(
|
||||||
|
async (
|
||||||
|
data: SessionData,
|
||||||
|
forSessionId?: string | null,
|
||||||
|
): Promise<void> => {
|
||||||
|
// If forSessionId is provided, verify it matches current session
|
||||||
|
// This prevents stale debounced saves from overwriting a newly switched session
|
||||||
|
if (
|
||||||
|
forSessionId !== undefined &&
|
||||||
|
forSessionId !== currentSessionId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
// Create a new session if none exists
|
||||||
|
const newSession: ChatSession = {
|
||||||
|
...createEmptySession(),
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||||
|
diagramHistory: data.diagramHistory,
|
||||||
|
title: extractTitle(data.messages),
|
||||||
|
}
|
||||||
|
await saveSession(newSession)
|
||||||
|
await enforceSessionLimit()
|
||||||
|
setCurrentSession(newSession)
|
||||||
|
setCurrentSessionId(newSession.id)
|
||||||
|
await refreshSessions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing session
|
||||||
|
const updatedSession: ChatSession = {
|
||||||
|
...currentSession,
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl:
|
||||||
|
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||||
|
diagramHistory:
|
||||||
|
data.diagramHistory ?? currentSession.diagramHistory,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
// Update title if it's still default and we have messages
|
||||||
|
title:
|
||||||
|
currentSession.title === "New Chat" &&
|
||||||
|
data.messages.length > 0
|
||||||
|
? extractTitle(data.messages)
|
||||||
|
: currentSession.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSession(updatedSession)
|
||||||
|
setCurrentSession(updatedSession)
|
||||||
|
|
||||||
|
// Update sessions list metadata
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === updatedSession.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
title: updatedSession.title,
|
||||||
|
updatedAt: updatedSession.updatedAt,
|
||||||
|
messageCount: updatedSession.messages.length,
|
||||||
|
hasDiagram:
|
||||||
|
!!updatedSession.diagramXml &&
|
||||||
|
updatedSession.diagramXml.trim().length > 0,
|
||||||
|
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currentSession, currentSessionId, refreshSessions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear current session state (for starting fresh without loading another session)
|
||||||
|
const clearCurrentSession = useCallback(() => {
|
||||||
|
setCurrentSession(null)
|
||||||
|
setCurrentSessionId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
currentSession,
|
||||||
|
isLoading,
|
||||||
|
isAvailable,
|
||||||
|
switchSession,
|
||||||
|
deleteSession,
|
||||||
|
saveCurrentSession,
|
||||||
|
refreshSessions,
|
||||||
|
clearCurrentSession,
|
||||||
|
}
|
||||||
|
}
|
||||||
136
hooks/use-validate-diagram.ts
Normal file
136
hooks/use-validate-diagram.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for VLM-based diagram validation using AI SDK's useObject.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
||||||
|
import { useCallback, useRef } from "react"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import {
|
||||||
|
type ValidationResult,
|
||||||
|
ValidationResultSchema,
|
||||||
|
} from "@/lib/validation-schema"
|
||||||
|
|
||||||
|
export type { ValidationResult }
|
||||||
|
|
||||||
|
// Default valid result for fallback cases
|
||||||
|
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
suggestions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseValidateDiagramOptions {
|
||||||
|
onSuccess?: (result: ValidationResult) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pending validation promises for imperative API
|
||||||
|
type PendingValidation = {
|
||||||
|
resolve: (result: ValidationResult) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||||
|
const { onSuccess, onError } = options
|
||||||
|
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
||||||
|
|
||||||
|
const { object, submit, isLoading, error, stop } = useObject({
|
||||||
|
api: getApiEndpoint("/api/validate-diagram"),
|
||||||
|
schema: ValidationResultSchema,
|
||||||
|
onFinish: ({
|
||||||
|
object,
|
||||||
|
error: finishError,
|
||||||
|
}: {
|
||||||
|
object: ValidationResult | undefined
|
||||||
|
error: Error | undefined
|
||||||
|
}) => {
|
||||||
|
if (finishError) {
|
||||||
|
console.error(
|
||||||
|
"[useValidateDiagram] Validation error:",
|
||||||
|
finishError,
|
||||||
|
)
|
||||||
|
onError?.(finishError)
|
||||||
|
pendingValidationRef.current?.reject(finishError)
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object) {
|
||||||
|
const result = object as ValidationResult
|
||||||
|
onSuccess?.(result)
|
||||||
|
pendingValidationRef.current?.resolve(result)
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
console.error("[useValidateDiagram] Stream error:", err)
|
||||||
|
onError?.(err)
|
||||||
|
pendingValidationRef.current?.reject(err)
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a diagram image.
|
||||||
|
* Returns a promise that resolves with the validation result.
|
||||||
|
*/
|
||||||
|
const validate = useCallback(
|
||||||
|
async (
|
||||||
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<ValidationResult> => {
|
||||||
|
// Reject any pending validation to prevent promise leaks
|
||||||
|
if (pendingValidationRef.current) {
|
||||||
|
pendingValidationRef.current.reject(
|
||||||
|
new Error("Validation superseded by new request"),
|
||||||
|
)
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Store the promise handlers
|
||||||
|
pendingValidationRef.current = { resolve, reject }
|
||||||
|
|
||||||
|
// Submit the validation request
|
||||||
|
submit({ imageData, sessionId })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[submit],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate with fallback - returns default valid result on error.
|
||||||
|
* Use this to avoid blocking the user on validation failures.
|
||||||
|
*/
|
||||||
|
const validateWithFallback = useCallback(
|
||||||
|
async (
|
||||||
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<ValidationResult> => {
|
||||||
|
try {
|
||||||
|
return await validate(imageData, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[useValidateDiagram] Validation failed, using fallback:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return DEFAULT_VALID_RESULT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Validation functions
|
||||||
|
validate,
|
||||||
|
validateWithFallback,
|
||||||
|
stop,
|
||||||
|
|
||||||
|
// State
|
||||||
|
isValidating: isLoading,
|
||||||
|
partialResult: object as ValidationResult | undefined,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { STORAGE_KEYS } from "./storage"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get AI configuration from localStorage.
|
|
||||||
* Returns API keys and settings for custom AI providers.
|
|
||||||
* Used to override server defaults when user provides their own API key.
|
|
||||||
*/
|
|
||||||
export function getAIConfig() {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return {
|
|
||||||
accessCode: "",
|
|
||||||
aiProvider: "",
|
|
||||||
aiBaseUrl: "",
|
|
||||||
aiApiKey: "",
|
|
||||||
aiModel: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
|
||||||
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
|
|
||||||
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
|
|
||||||
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
|
|
||||||
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,31 +4,57 @@ import { azure, createAzure } from "@ai-sdk/azure"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway, gateway } from "@ai-sdk/gateway"
|
import { createGateway, gateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||||
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||||
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
export type ProviderName =
|
export type { ProviderName }
|
||||||
| "bedrock"
|
|
||||||
| "openai"
|
|
||||||
| "anthropic"
|
|
||||||
| "google"
|
|
||||||
| "azure"
|
|
||||||
| "ollama"
|
|
||||||
| "openrouter"
|
|
||||||
| "deepseek"
|
|
||||||
| "siliconflow"
|
|
||||||
| "sglang"
|
|
||||||
| "gateway"
|
|
||||||
| "edgeone"
|
|
||||||
| "doubao"
|
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
providerOptions?: any
|
providerOptions?: any
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
modelId: string
|
modelId: string
|
||||||
|
provider: ProviderName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers that only support a single system message
|
||||||
|
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
||||||
|
"minimax",
|
||||||
|
"glm",
|
||||||
|
"qwen",
|
||||||
|
"kimi",
|
||||||
|
"qiniu",
|
||||||
|
"novita",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize MiniMax base URL for AI SDK compatibility.
|
||||||
|
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
||||||
|
*/
|
||||||
|
export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||||
|
baseURL: string
|
||||||
|
isAnthropicCompatible: boolean
|
||||||
|
} {
|
||||||
|
const isAnthropicCompatible = rawUrl.includes("/anthropic")
|
||||||
|
let baseURL = rawUrl.replace(/\/$/, "")
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
if (!baseURL.endsWith("/anthropic/v1")) {
|
||||||
|
if (baseURL.endsWith("/anthropic")) {
|
||||||
|
baseURL = `${baseURL}/v1`
|
||||||
|
} else {
|
||||||
|
baseURL = `${baseURL}/anthropic/v1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!baseURL.endsWith("/v1")) {
|
||||||
|
baseURL = `${baseURL}/v1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { baseURL, isAnthropicCompatible }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientOverrides {
|
export interface ClientOverrides {
|
||||||
@@ -41,15 +67,22 @@ export interface ClientOverrides {
|
|||||||
awsSecretAccessKey?: string | null
|
awsSecretAccessKey?: string | null
|
||||||
awsRegion?: string | null
|
awsRegion?: string | null
|
||||||
awsSessionToken?: string | null
|
awsSessionToken?: string | null
|
||||||
|
// Vertex AI config
|
||||||
|
vertexApiKey?: string | null // Express Mode API key
|
||||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
// Custom env var name(s) for server models
|
||||||
|
// Can be a single string or array of strings for load balancing
|
||||||
|
apiKeyEnv?: string | string[]
|
||||||
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be selected from client settings
|
||||||
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
|
"vertexai",
|
||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
@@ -58,7 +91,15 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"sglang",
|
"sglang",
|
||||||
"gateway",
|
"gateway",
|
||||||
"edgeone",
|
"edgeone",
|
||||||
|
"ollama",
|
||||||
"doubao",
|
"doubao",
|
||||||
|
"modelscope",
|
||||||
|
"glm",
|
||||||
|
"qwen",
|
||||||
|
"qiniu",
|
||||||
|
"kimi",
|
||||||
|
"minimax",
|
||||||
|
"novita",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -73,6 +114,87 @@ const ANTHROPIC_BETA_HEADERS = {
|
|||||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve baseURL based on whether user is providing their own API key.
|
||||||
|
* When user provides their own API key, we should NOT fall back to server's
|
||||||
|
* baseURL environment variable - user credentials should only be sent to
|
||||||
|
* user-specified endpoints or official provider endpoints.
|
||||||
|
*
|
||||||
|
* @param userApiKey - User-provided API key (if any)
|
||||||
|
* @param userBaseUrl - User-provided base URL (if any)
|
||||||
|
* @param serverBaseUrl - Server's base URL from environment variable
|
||||||
|
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
||||||
|
* @returns The resolved base URL to use
|
||||||
|
*/
|
||||||
|
export function resolveBaseURL(
|
||||||
|
userApiKey: string | null | undefined,
|
||||||
|
userBaseUrl: string | null | undefined,
|
||||||
|
serverBaseUrl: string | undefined,
|
||||||
|
defaultBaseUrl?: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (userApiKey) {
|
||||||
|
// User provides their own API key - only use user's baseUrl or default
|
||||||
|
return userBaseUrl || defaultBaseUrl || undefined
|
||||||
|
}
|
||||||
|
// No user API key - fall back to server config
|
||||||
|
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve API key from custom env var name or default env var.
|
||||||
|
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
||||||
|
* When multiple keys are configured, randomly selects one for load balancing.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. User-provided API key (overrides.apiKey)
|
||||||
|
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv)
|
||||||
|
* - If array, randomly picks one with a valid value
|
||||||
|
* 3. Default provider env var (defaultEnvVar)
|
||||||
|
*/
|
||||||
|
function resolveApiKey(
|
||||||
|
overrides: ClientOverrides | undefined,
|
||||||
|
defaultEnvVar: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (overrides?.apiKey) return overrides.apiKey
|
||||||
|
|
||||||
|
if (overrides?.apiKeyEnv) {
|
||||||
|
// Handle array of env var names - randomly select one
|
||||||
|
if (Array.isArray(overrides.apiKeyEnv)) {
|
||||||
|
// Filter to only env vars that have values
|
||||||
|
const validEnvVars = overrides.apiKeyEnv.filter(
|
||||||
|
(envVar) => process.env[envVar],
|
||||||
|
)
|
||||||
|
if (validEnvVars.length > 0) {
|
||||||
|
// Randomly select one
|
||||||
|
const selectedEnvVar =
|
||||||
|
validEnvVars[
|
||||||
|
Math.floor(Math.random() * validEnvVars.length)
|
||||||
|
]
|
||||||
|
console.log(
|
||||||
|
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
|
||||||
|
)
|
||||||
|
return process.env[selectedEnvVar]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return process.env[overrides.apiKeyEnv]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env[defaultEnvVar]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve base URL from custom env var name or default env var.
|
||||||
|
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
|
||||||
|
*/
|
||||||
|
function resolveBaseUrlEnv(
|
||||||
|
overrides: ClientOverrides | undefined,
|
||||||
|
defaultEnvVar: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
|
||||||
|
return process.env[defaultEnvVar]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse integer from environment variable with validation
|
* Safely parse integer from environment variable with validation
|
||||||
*/
|
*/
|
||||||
@@ -107,6 +229,8 @@ function parseIntSafe(
|
|||||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
||||||
|
* - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
|
* - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)
|
||||||
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
||||||
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
||||||
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
||||||
@@ -271,7 +395,46 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "vertexai": {
|
||||||
|
const thinkingBudget = parseIntSafe(
|
||||||
|
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
||||||
|
"GOOGLE_VERTEX_THINKING_BUDGET",
|
||||||
|
1024,
|
||||||
|
100000,
|
||||||
|
)
|
||||||
|
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
||||||
|
|
||||||
|
if (
|
||||||
|
modelId &&
|
||||||
|
(modelId.includes("gemini-2") ||
|
||||||
|
modelId.includes("gemini-3") ||
|
||||||
|
modelId.includes("gemini2") ||
|
||||||
|
modelId.includes("gemini3"))
|
||||||
|
) {
|
||||||
|
const thinkingConfig: Record<string, any> = {
|
||||||
|
includeThoughts: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGemini3 =
|
||||||
|
modelId?.includes("gemini-3") ||
|
||||||
|
modelId?.includes("gemini3")
|
||||||
|
const isGemini25 =
|
||||||
|
modelId?.includes("2.5") || modelId?.includes("2-5")
|
||||||
|
|
||||||
|
if (isGemini3 && thinkingLevel) {
|
||||||
|
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
|
||||||
|
thinkingConfig.thinkingLevel = thinkingLevel as
|
||||||
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
} else if (isGemini25 && thinkingBudget) {
|
||||||
|
thinkingConfig.thinkingBudget = thinkingBudget
|
||||||
|
}
|
||||||
|
options.google = { thinkingConfig }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
||||||
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
||||||
@@ -353,7 +516,14 @@ function buildProviderOptions(
|
|||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
case "doubao": {
|
case "modelscope":
|
||||||
|
case "doubao":
|
||||||
|
case "minimax":
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "kimi":
|
||||||
|
case "qiniu":
|
||||||
|
case "novita": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
break
|
break
|
||||||
@@ -372,6 +542,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
|
vertexai: "GOOGLE_VERTEX_API_KEY",
|
||||||
azure: "AZURE_API_KEY",
|
azure: "AZURE_API_KEY",
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
@@ -381,6 +552,13 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
||||||
doubao: "DOUBAO_API_KEY",
|
doubao: "DOUBAO_API_KEY",
|
||||||
|
modelscope: "MODELSCOPE_API_KEY",
|
||||||
|
glm: "GLM_API_KEY",
|
||||||
|
qwen: "QWEN_API_KEY",
|
||||||
|
qiniu: "QINIU_API_KEY",
|
||||||
|
kimi: "KIMI_API_KEY",
|
||||||
|
minimax: "MINIMAX_API_KEY",
|
||||||
|
novita: "NOVITA_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,9 +596,27 @@ function detectProvider(): ProviderName | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that required API keys are present for the selected provider
|
* Validate that required API keys are present for the selected provider
|
||||||
|
* @param provider - The provider to validate
|
||||||
|
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
|
||||||
*/
|
*/
|
||||||
function validateProviderCredentials(provider: ProviderName): void {
|
function validateProviderCredentials(
|
||||||
const requiredVar = PROVIDER_ENV_VARS[provider]
|
provider: ProviderName,
|
||||||
|
customApiKeyEnv?: string | string[],
|
||||||
|
): void {
|
||||||
|
// Handle array of env var names - at least one must be set
|
||||||
|
if (Array.isArray(customApiKeyEnv)) {
|
||||||
|
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
|
||||||
|
if (!hasAnyKey) {
|
||||||
|
throw new Error(
|
||||||
|
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
|
||||||
|
`Please set at least one in your .env.local file.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom env var name if provided, otherwise use default
|
||||||
|
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||||
@@ -445,7 +641,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -455,24 +651,30 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
|
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
|
||||||
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
|
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
|
||||||
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
||||||
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to http://localhost:11434)
|
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
||||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1)
|
||||||
* - SGLANG_API_KEY: SGLang API key
|
* - SGLANG_API_KEY: SGLang API key
|
||||||
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||||
|
* - MODELSCOPE_API_KEY: ModelScope API key
|
||||||
|
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
|
||||||
*/
|
*/
|
||||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||||
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
|
// Exception: EdgeOne doesn't require API keys.
|
||||||
|
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
|
||||||
|
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
|
||||||
if (
|
if (
|
||||||
overrides?.baseUrl &&
|
overrides?.baseUrl &&
|
||||||
!overrides?.apiKey &&
|
!overrides?.apiKey &&
|
||||||
overrides?.provider !== "edgeone"
|
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||||
|
overrides?.provider !== "edgeone" &&
|
||||||
|
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`API key is required when using a custom base URL. ` +
|
`API key is required when using a custom base URL. ` +
|
||||||
@@ -481,7 +683,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if client is providing their own provider override
|
// Check if client is providing their own provider override
|
||||||
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
|
const isClientOverride = !!(
|
||||||
|
overrides?.provider &&
|
||||||
|
(overrides?.apiKey ||
|
||||||
|
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||||
|
)
|
||||||
|
|
||||||
// Use client override if provided, otherwise fall back to env vars
|
// Use client override if provided, otherwise fall back to env vars
|
||||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||||
@@ -537,6 +743,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
|
`- MODELSCOPE_API_KEY for ModelScope\n` +
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -550,7 +757,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
// Only validate server credentials if client isn't providing their own API key
|
// Only validate server credentials if client isn't providing their own API key
|
||||||
if (!isClientOverride) {
|
if (!isClientOverride) {
|
||||||
validateProviderCredentials(provider)
|
validateProviderCredentials(provider, overrides?.apiKeyEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||||
@@ -573,8 +780,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
const bedrockProvider = hasClientCredentials
|
const bedrockProvider = hasClientCredentials
|
||||||
? createAmazonBedrock({
|
? createAmazonBedrock({
|
||||||
region: bedrockRegion,
|
region: bedrockRegion,
|
||||||
accessKeyId: overrides.awsAccessKeyId!,
|
accessKeyId: overrides.awsAccessKeyId as string,
|
||||||
secretAccessKey: overrides.awsSecretAccessKey!,
|
secretAccessKey: overrides.awsSecretAccessKey as string,
|
||||||
...(overrides?.awsSessionToken && {
|
...(overrides?.awsSessionToken && {
|
||||||
sessionToken: overrides.awsSessionToken,
|
sessionToken: overrides.awsSessionToken,
|
||||||
}),
|
}),
|
||||||
@@ -600,8 +807,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
|
||||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"OPENAI_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
if (baseURL) {
|
if (baseURL) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
// Custom base URL = third-party proxy, use Chat Completions API
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
// for compatibility (most proxies don't support /responses endpoint)
|
||||||
@@ -619,11 +834,17 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
|
||||||
const baseURL =
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
overrides?.baseUrl ||
|
overrides,
|
||||||
process.env.ANTHROPIC_BASE_URL ||
|
"ANTHROPIC_BASE_URL",
|
||||||
"https://api.anthropic.com/v1"
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
)
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -636,9 +857,19 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const apiKey =
|
const apiKey = resolveApiKey(
|
||||||
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
overrides,
|
||||||
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
|
)
|
||||||
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"GOOGLE_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customGoogle = createGoogleGenerativeAI({
|
const customGoogle = createGoogleGenerativeAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -650,11 +881,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "vertexai": {
|
||||||
|
// Express Mode: Use API key for authentication
|
||||||
|
const vertexApiKey =
|
||||||
|
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
||||||
|
|
||||||
|
if (!vertexApiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Vertex AI requires an API key for Express Mode. " +
|
||||||
|
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support custom base URL from env or client override
|
||||||
|
const baseURL =
|
||||||
|
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
|
||||||
|
|
||||||
|
const vertexProvider = createVertex({
|
||||||
|
apiKey: vertexApiKey,
|
||||||
|
...(baseURL && { baseURL }),
|
||||||
|
})
|
||||||
|
model = vertexProvider(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
|
||||||
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
|
||||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
|
// Only use server's resourceName if user is NOT providing their own API key
|
||||||
|
const resourceName = overrides?.apiKey
|
||||||
|
? undefined
|
||||||
|
: process.env.AZURE_RESOURCE_NAME
|
||||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
// Azure requires either baseURL or resourceName to construct the endpoint
|
||||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
||||||
if (baseURL || resourceName || overrides?.apiKey) {
|
if (baseURL || resourceName || overrides?.apiKey) {
|
||||||
@@ -671,21 +933,39 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ollama":
|
case "ollama": {
|
||||||
if (process.env.OLLAMA_BASE_URL) {
|
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
||||||
|
// SECURITY: When client provides a custom base URL, only use
|
||||||
|
// client-provided API key. Never fall back to server OLLAMA_API_KEY
|
||||||
|
// to prevent leaking server credentials to user-controlled endpoints.
|
||||||
|
const apiKey = overrides?.baseUrl
|
||||||
|
? overrides?.apiKey || undefined
|
||||||
|
: resolveApiKey(overrides, "OLLAMA_API_KEY")
|
||||||
|
if (baseURL || apiKey) {
|
||||||
const customOllama = createOllama({
|
const customOllama = createOllama({
|
||||||
baseURL: process.env.OLLAMA_BASE_URL,
|
...(baseURL && { baseURL }),
|
||||||
|
...(apiKey && {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
model = customOllama(modelId)
|
model = customOllama(modelId)
|
||||||
} else {
|
} else {
|
||||||
model = ollama(modelId)
|
model = ollama(modelId)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "openrouter": {
|
case "openrouter": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
|
||||||
const baseURL =
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
|
overrides,
|
||||||
|
"OPENROUTER_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
const openrouter = createOpenRouter({
|
const openrouter = createOpenRouter({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
...(baseURL && { baseURL }),
|
||||||
@@ -695,8 +975,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
||||||
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"DEEPSEEK_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customDeepSeek = createDeepSeek({
|
const customDeepSeek = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -710,11 +998,17 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
|
||||||
const baseURL =
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
overrides?.baseUrl ||
|
overrides,
|
||||||
process.env.SILICONFLOW_BASE_URL ||
|
"SILICONFLOW_BASE_URL",
|
||||||
"https://api.siliconflow.com/v1"
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
"https://api.siliconflow.cn/v1",
|
||||||
|
)
|
||||||
const siliconflowProvider = createOpenAI({
|
const siliconflowProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -724,12 +1018,20 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "sglang": {
|
case "sglang": {
|
||||||
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
|
||||||
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"SGLANG_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
const sglangProvider = createOpenAI({
|
const sglangProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
...(baseURL && { baseURL }),
|
||||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||||
fetch: async (url, options) => {
|
fetch: async (url, options) => {
|
||||||
const response = await fetch(url, options)
|
const response = await fetch(url, options)
|
||||||
@@ -786,7 +1088,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`data: ${JSON.stringify(data)}\n\n`,
|
`data: ${JSON.stringify(data)}\n\n`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// If parsing fails, forward the original message to avoid breaking the stream.
|
// If parsing fails, forward the original message to avoid breaking the stream.
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
@@ -833,9 +1135,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
// See: https://vercel.com/ai-gateway
|
// See: https://vercel.com/ai-gateway
|
||||||
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
|
||||||
const baseURL =
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
|
overrides,
|
||||||
|
"AI_GATEWAY_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
)
|
||||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
@@ -866,22 +1175,122 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
|
||||||
const baseURL =
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
overrides?.baseUrl ||
|
overrides,
|
||||||
process.env.DOUBAO_BASE_URL ||
|
"DOUBAO_BASE_URL",
|
||||||
"https://ark.cn-beijing.volces.com/api/v3"
|
)
|
||||||
const doubaoProvider = createDeepSeek({
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
"https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
)
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("deepseek") ||
|
||||||
|
lowerModelId.includes("kimi")
|
||||||
|
) {
|
||||||
|
const doubaoProvider = createDeepSeek({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = doubaoProvider(modelId)
|
||||||
|
} else {
|
||||||
|
const doubaoProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = doubaoProvider.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "modelscope": {
|
||||||
|
const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
|
||||||
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"MODELSCOPE_BASE_URL",
|
||||||
|
)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
"https://api-inference.modelscope.cn/v1",
|
||||||
|
)
|
||||||
|
const modelscopeProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
})
|
||||||
model = doubaoProvider(modelId)
|
model = modelscopeProvider.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "minimax": {
|
||||||
|
const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY")
|
||||||
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"MINIMAX_BASE_URL",
|
||||||
|
)
|
||||||
|
const rawBaseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
PROVIDER_INFO.minimax.defaultBaseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rawBaseURL) {
|
||||||
|
throw new Error(
|
||||||
|
"MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseURL, isAnthropicCompatible } =
|
||||||
|
normalizeMiniMaxBaseURL(rawBaseURL)
|
||||||
|
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
const minimax = createAnthropic({ apiKey, baseURL })
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
} else {
|
||||||
|
const minimax = createOpenAI({ apiKey, baseURL })
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "qiniu":
|
||||||
|
case "kimi":
|
||||||
|
case "novita": {
|
||||||
|
const envVar = PROVIDER_ENV_VARS[provider]
|
||||||
|
if (!envVar) {
|
||||||
|
throw new Error(
|
||||||
|
`API key environment variable not defined for provider: ${provider}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const apiKey = resolveApiKey(overrides, envVar)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
`${provider.toUpperCase()}_BASE_URL`,
|
||||||
|
),
|
||||||
|
PROVIDER_INFO[provider]?.defaultBaseUrl,
|
||||||
|
)
|
||||||
|
const customProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = customProvider.chat(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,7 +1299,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
providerOptions = customProviderOptions
|
providerOptions = customProviderOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
return { model, providerOptions, headers, modelId }
|
return { model, providerOptions, headers, modelId, provider }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -906,3 +1315,89 @@ export function supportsPromptCaching(modelId: string): boolean {
|
|||||||
modelId.startsWith("eu.anthropic")
|
modelId.startsWith("eu.anthropic")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model supports image/vision input.
|
||||||
|
* Some models silently drop image parts without error (AI SDK warning only).
|
||||||
|
*/
|
||||||
|
export function supportsImageInput(modelId: string): boolean {
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
|
||||||
|
// Helper to check if model has vision capability indicator
|
||||||
|
const hasVisionIndicator =
|
||||||
|
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||||
|
|
||||||
|
// Models that DON'T support image/vision input (unless vision variant)
|
||||||
|
// Kimi K2 doesn't support images, but K2.5 does
|
||||||
|
// Only block kimi-k2 specifically, not other Kimi models
|
||||||
|
if (
|
||||||
|
(lowerModelId.includes("kimi-k2") ||
|
||||||
|
lowerModelId.includes("kimi_k2")) &&
|
||||||
|
!hasVisionIndicator &&
|
||||||
|
!lowerModelId.includes("2.5") &&
|
||||||
|
!lowerModelId.includes("k2.5")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moonshot text models (moonshot-v1 series are text-only)
|
||||||
|
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax text models (MiniMax-M2.x series are text-only)
|
||||||
|
if (lowerModelId.includes("minimax") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek text models (not vision variants)
|
||||||
|
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qwen text models (not vision variants like qwen-vl)
|
||||||
|
// qwen3.5-plus is a vision model
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("qwen") &&
|
||||||
|
!hasVisionIndicator &&
|
||||||
|
!lowerModelId.includes("qwen3.5-plus") &&
|
||||||
|
!lowerModelId.includes("qwen3.5-flash")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM text models (not vision variants)
|
||||||
|
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
|
||||||
|
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
|
||||||
|
if (!/[\d.]v/.test(lowerModelId)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: assume model supports images
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AI model for diagram validation.
|
||||||
|
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
||||||
|
* Throws if the model doesn't support image input.
|
||||||
|
*/
|
||||||
|
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||||
|
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
throw new Error(
|
||||||
|
"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsImageInput(modelId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { model } = getAIModel({ modelId })
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|||||||
89
lib/chat-helpers.ts
Normal file
89
lib/chat-helpers.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Shared helper functions for chat route
|
||||||
|
// Exported for testing
|
||||||
|
|
||||||
|
// File upload limits (must match client-side)
|
||||||
|
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
|
export const MAX_FILES = 5
|
||||||
|
|
||||||
|
// Helper function to validate file parts in messages
|
||||||
|
export function validateFileParts(messages: any[]): {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const fileParts =
|
||||||
|
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePart of fileParts) {
|
||||||
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
|
if (filePart.url?.startsWith("data:")) {
|
||||||
|
const base64Data = filePart.url.split(",")[1]
|
||||||
|
if (base64Data) {
|
||||||
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
|
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if diagram is minimal/empty
|
||||||
|
export function isMinimalDiagram(xml: string): boolean {
|
||||||
|
const stripped = xml.replace(/\s/g, "")
|
||||||
|
return !stripped.includes('id="2"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to replace historical tool call XML with placeholders
|
||||||
|
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||||
|
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||||
|
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||||
|
return messages.map((msg) => {
|
||||||
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
const replacedContent = msg.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
const toolName = part.toolName
|
||||||
|
// Fix invalid/undefined inputs from interrupted streaming
|
||||||
|
if (
|
||||||
|
!part.input ||
|
||||||
|
typeof part.input !== "object" ||
|
||||||
|
Object.keys(part.input).length === 0
|
||||||
|
) {
|
||||||
|
// Skip tool calls with invalid inputs entirely
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
toolName === "display_diagram" ||
|
||||||
|
toolName === "edit_diagram"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
input: {
|
||||||
|
placeholder:
|
||||||
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||||
|
return { ...msg, content: replacedContent }
|
||||||
|
})
|
||||||
|
}
|
||||||
64
lib/diagram-validator.ts
Normal file
64
lib/diagram-validator.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Types and utilities for VLM-based diagram validation.
|
||||||
|
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export types from the schema file (single source of truth)
|
||||||
|
export type { ValidationIssue, ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
|
import type { ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format validation feedback for display to the AI model.
|
||||||
|
* This creates a human-readable error message that guides the AI to fix issues.
|
||||||
|
*
|
||||||
|
* @param result - The validation result from VLM
|
||||||
|
* @returns Formatted string for tool error output
|
||||||
|
*/
|
||||||
|
export function formatValidationFeedback(result: ValidationResult): string {
|
||||||
|
// If validation passed with no issues, return empty string
|
||||||
|
if (result.valid && result.issues.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
|
||||||
|
lines.push("")
|
||||||
|
|
||||||
|
// Group issues by severity
|
||||||
|
const criticalIssues = result.issues.filter(
|
||||||
|
(i) => i.severity === "critical",
|
||||||
|
)
|
||||||
|
const warnings = result.issues.filter((i) => i.severity === "warning")
|
||||||
|
|
||||||
|
if (criticalIssues.length > 0) {
|
||||||
|
lines.push("Critical Issues (must fix):")
|
||||||
|
for (const issue of criticalIssues) {
|
||||||
|
lines.push(` - [${issue.type}] ${issue.description}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
lines.push("Warnings:")
|
||||||
|
for (const issue of warnings) {
|
||||||
|
lines.push(` - [${issue.type}] ${issue.description}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.suggestions.length > 0) {
|
||||||
|
lines.push("Suggestions to fix:")
|
||||||
|
for (const suggestion of result.suggestions) {
|
||||||
|
lines.push(` - ${suggestion}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
"Please regenerate the diagram with corrected layout to fix these visual issues.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const i18n = {
|
export const i18n = {
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
locales: ["en", "zh", "ja"],
|
locales: ["en", "zh", "ja", "zh-Hant"],
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Locale = (typeof i18n)["locales"][number]
|
export type Locale = (typeof i18n)["locales"][number]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const dictionaries = {
|
|||||||
en: () => import("./dictionaries/en.json").then((m) => m.default),
|
en: () => import("./dictionaries/en.json").then((m) => m.default),
|
||||||
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
|
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
|
||||||
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
|
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
|
||||||
|
"zh-Hant": () =>
|
||||||
|
import("./dictionaries/zh-Hant.json").then((m) => m.default),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
|
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
|
||||||
|
|||||||
@@ -28,12 +28,18 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"sending": "Sending...",
|
"stopGeneration": "Stop generation",
|
||||||
"sendMessage": "Send message",
|
"sendMessage": "Send message",
|
||||||
"clearConversation": "Clear conversation",
|
"clearConversation": "Clear conversation",
|
||||||
"diagramHistory": "Diagram history",
|
"diagramHistory": "Diagram history",
|
||||||
@@ -51,7 +57,8 @@
|
|||||||
"badResponse": "Bad response",
|
"badResponse": "Bad response",
|
||||||
"clickToEdit": "Click to edit",
|
"clickToEdit": "Click to edit",
|
||||||
"editMessage": "Edit message",
|
"editMessage": "Edit message",
|
||||||
"saveAndSubmit": "Save & Submit"
|
"saveAndSubmit": "Save & Submit",
|
||||||
|
"ExtractURL": "Extract from URL"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "Create diagrams with AI",
|
"title": "Create diagrams with AI",
|
||||||
@@ -69,12 +76,13 @@
|
|||||||
"creativeDescription": "Draw something fun and creative",
|
"creativeDescription": "Draw something fun and creative",
|
||||||
"cachedNote": "Examples are cached for instant response",
|
"cachedNote": "Examples are cached for instant response",
|
||||||
"mcpServer": "MCP Server",
|
"mcpServer": "MCP Server",
|
||||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
|
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||||
"preview": "PREVIEW"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"description": "Configure your application settings.",
|
"description": "Configure your application settings.",
|
||||||
|
"apiKeysModels": "API Keys & Models",
|
||||||
|
"apiKeysModelsDescription": "Configure AI providers and API keys.",
|
||||||
"accessCode": "Access Code",
|
"accessCode": "Access Code",
|
||||||
"accessCodePlaceholder": "Enter access code",
|
"accessCodePlaceholder": "Enter access code",
|
||||||
"accessCodeDescription": "Required to use this application.",
|
"accessCodeDescription": "Required to use this application.",
|
||||||
@@ -98,14 +106,34 @@
|
|||||||
"switchTo": "Switch to",
|
"switchTo": "Switch to",
|
||||||
"minimal": "Minimal",
|
"minimal": "Minimal",
|
||||||
"sketch": "Sketch",
|
"sketch": "Sketch",
|
||||||
"closeProtection": "Close Protection",
|
|
||||||
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
|
||||||
"diagramStyle": "Diagram Style",
|
"diagramStyle": "Diagram Style",
|
||||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||||
|
"sendShortcut": "Send Shortcut",
|
||||||
|
"sendShortcutDescription": "Choose how to send messages.",
|
||||||
|
"enterToSend": "Enter to send",
|
||||||
|
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
||||||
"diagramActions": "Diagram Actions",
|
"diagramActions": "Diagram Actions",
|
||||||
"diagramActionsDescription": "Manage diagram history and exports",
|
"diagramActionsDescription": "Manage diagram history and exports",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"download": "Download"
|
"download": "Download",
|
||||||
|
"proxy": "Proxy Settings",
|
||||||
|
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
|
||||||
|
"httpProxy": "HTTP Proxy",
|
||||||
|
"httpsProxy": "HTTPS Proxy",
|
||||||
|
"applyProxy": "Apply",
|
||||||
|
"proxyApplied": "Proxy settings applied",
|
||||||
|
"diagramValidation": "Diagram Validation (Experimental)",
|
||||||
|
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"customSystemMessage": "Custom System Message",
|
||||||
|
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||||
|
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||||
|
"panelVisibility": "Lobby Panels",
|
||||||
|
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||||
|
"showRecentChats": "Recent Chats",
|
||||||
|
"showMyTemplates": "My Templates",
|
||||||
|
"showQuickExamples": "Quick Examples"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "Save Diagram",
|
"title": "Save Diagram",
|
||||||
@@ -116,8 +144,10 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG Image",
|
"png": "PNG Image",
|
||||||
"svg": "SVG Image"
|
"svg": "SVG Image",
|
||||||
}
|
"xmlsvg": "Editable SVG"
|
||||||
|
},
|
||||||
|
"savedSuccessfully": "Saved successfully!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Diagram History",
|
"title": "Diagram History",
|
||||||
@@ -162,8 +192,11 @@
|
|||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
"tpmMessage": "Too many requests. Please wait a moment.",
|
||||||
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||||
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||||
|
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
|
||||||
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
||||||
"configModel": "Use Your API Key",
|
"configModel": "Use Your API Key",
|
||||||
@@ -185,6 +218,15 @@
|
|||||||
"chars": "chars",
|
"chars": "chars",
|
||||||
"removeFile": "Remove file"
|
"removeFile": "Remove file"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "Extract Content from URL",
|
||||||
|
"description": "Paste a URL to extract and analyze its content",
|
||||||
|
"Extracting": "Extracting...",
|
||||||
|
"extract": "Extract",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"enterUrl": "Please enter a URL",
|
||||||
|
"invalidFormat": "Invalid URL format"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "Thinking...",
|
"thinking": "Thinking...",
|
||||||
"thoughtFor": "Thought for {duration} seconds",
|
"thoughtFor": "Thought for {duration} seconds",
|
||||||
@@ -212,6 +254,86 @@
|
|||||||
"contactMe": "Contact Me",
|
"contactMe": "Contact Me",
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "Chat History",
|
||||||
|
"newChat": "New Chat",
|
||||||
|
"empty": "No chat history yet",
|
||||||
|
"emptyHint": "Start a conversation to begin",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"thisWeek": "This Week",
|
||||||
|
"earlier": "Earlier",
|
||||||
|
"deleteTitle": "Delete this chat?",
|
||||||
|
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
|
||||||
|
"recentChats": "Recent Chats",
|
||||||
|
"justNow": "Just now",
|
||||||
|
"searchPlaceholder": "Search chats...",
|
||||||
|
"noResults": "No chats found"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "My Templates",
|
||||||
|
"subtitle": "Your personal prompt library for quick diagram creation",
|
||||||
|
"emptyTitle": "No templates yet",
|
||||||
|
"emptyDescription": "Create your first template to start building your personal prompt library",
|
||||||
|
"createFirst": "Create First Template",
|
||||||
|
"neverUsed": "Not used yet",
|
||||||
|
"usedCount": "{count} uses",
|
||||||
|
"myTemplates": "My Templates",
|
||||||
|
"createTitle": "Create Template",
|
||||||
|
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
|
||||||
|
"promptLabel": "Prompt",
|
||||||
|
"promptPlaceholder": "Describe the diagram you want to create...",
|
||||||
|
"promptRequired": "Prompt is required",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"titlePlaceholder": "Enter a title",
|
||||||
|
"titleHint": "Leave empty to use the first 20 characters of prompt",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"descriptionPlaceholder": "Add a description for this template",
|
||||||
|
"pinnedLabel": "Pin Template",
|
||||||
|
"pinnedHint": "Pinned templates appear at the top of the list",
|
||||||
|
"createButton": "Create Template",
|
||||||
|
"createFailed": "Failed to create template. Please try again.",
|
||||||
|
"editTitle": "Edit Template",
|
||||||
|
"editDescription": "Update your template content and settings.",
|
||||||
|
"updateFailed": "Failed to update template. Please try again.",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"copySuffix": "(copy)",
|
||||||
|
"deleteTitle": "Delete this template?",
|
||||||
|
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
|
||||||
|
"confirmSendTitle": "Replace current input?",
|
||||||
|
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
|
||||||
|
"confirmSendButton": "Send Template",
|
||||||
|
"searchPlaceholder": "Search templates...",
|
||||||
|
"searchNoResults": "No templates match your search",
|
||||||
|
"pin": "Pin to top",
|
||||||
|
"unpin": "Unpin from top",
|
||||||
|
"saveAsTemplate": "Save as Template",
|
||||||
|
"exportTemplates": "Export Templates",
|
||||||
|
"importTemplates": "Import Templates",
|
||||||
|
"exportEmpty": "No templates to export",
|
||||||
|
"exportSuccess": "Exported {count} template(s) successfully",
|
||||||
|
"importNoFile": "Please select a JSON file",
|
||||||
|
"importFailed": "Import failed: {error}",
|
||||||
|
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"title": "Validate Diagram",
|
||||||
|
"capturing": "Capturing",
|
||||||
|
"validating": "Validating",
|
||||||
|
"validatingWithAttempt": "Validating ({attempt}/{max})",
|
||||||
|
"valid": "Valid",
|
||||||
|
"validWithWarnings": "Valid with Warnings",
|
||||||
|
"issuesFound": "Issues Found",
|
||||||
|
"error": "Error",
|
||||||
|
"skipped": "Skipped",
|
||||||
|
"capturedScreenshot": "Captured Screenshot:",
|
||||||
|
"issuesFoundLabel": "Issues Found:",
|
||||||
|
"suggestions": "Suggestions:",
|
||||||
|
"passedValidation": "Diagram passed visual validation - no issues detected.",
|
||||||
|
"improvementRequested": "Improvement requested - check the new diagram below",
|
||||||
|
"improveWithSuggestions": "Improve with Suggestions",
|
||||||
|
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI Model Configuration",
|
"title": "AI Model Configuration",
|
||||||
"description": "Configure multiple AI providers and models",
|
"description": "Configure multiple AI providers and models",
|
||||||
@@ -244,7 +366,9 @@
|
|||||||
"enterSecretKey": "Enter your secret access key",
|
"enterSecretKey": "Enter your secret access key",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
|
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
|
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
"allAdded": "All added",
|
"allAdded": "All added",
|
||||||
@@ -269,10 +393,13 @@
|
|||||||
"noModelsFound": "No models found.",
|
"noModelsFound": "No models found.",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"serverDefault": "Server Default",
|
"serverDefault": "Server Default",
|
||||||
|
"serverModels": "Server Models",
|
||||||
|
"userModels": "User Models",
|
||||||
"configureModels": "Configure Models...",
|
"configureModels": "Configure Models...",
|
||||||
"onlyVerifiedShown": "Only verified models are shown",
|
"onlyVerifiedShown": "Only verified models are shown",
|
||||||
"showUnvalidatedModels": "Show unvalidated models",
|
"showUnvalidatedModels": "Show unvalidated models",
|
||||||
"allModelsShown": "All models are shown (including unvalidated)",
|
"allModelsShown": "All models are shown (including unvalidated)",
|
||||||
"unvalidatedModelWarning": "This model has not been validated"
|
"unvalidatedModelWarning": "This model has not been validated",
|
||||||
|
"serverDefaultModel": "Server default model"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,18 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
"send": "送信",
|
"send": "送信",
|
||||||
"sending": "送信中...",
|
"stopGeneration": "生成を停止",
|
||||||
"sendMessage": "メッセージを送信",
|
"sendMessage": "メッセージを送信",
|
||||||
"clearConversation": "会話をクリア",
|
"clearConversation": "会話をクリア",
|
||||||
"diagramHistory": "ダイアグラム履歴",
|
"diagramHistory": "ダイアグラム履歴",
|
||||||
@@ -51,7 +57,8 @@
|
|||||||
"badResponse": "悪い応答",
|
"badResponse": "悪い応答",
|
||||||
"clickToEdit": "クリックして編集",
|
"clickToEdit": "クリックして編集",
|
||||||
"editMessage": "メッセージを編集",
|
"editMessage": "メッセージを編集",
|
||||||
"saveAndSubmit": "保存して送信"
|
"saveAndSubmit": "保存して送信",
|
||||||
|
"ExtractURL": "URLから抽出"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "AI でダイアグラムを作成",
|
"title": "AI でダイアグラムを作成",
|
||||||
@@ -69,12 +76,13 @@
|
|||||||
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
||||||
"cachedNote": "例はキャッシュされ、即座に応答します",
|
"cachedNote": "例はキャッシュされ、即座に応答します",
|
||||||
"mcpServer": "MCP サーバー",
|
"mcpServer": "MCP サーバー",
|
||||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
|
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
|
||||||
"preview": "プレビュー"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
"description": "アプリケーション設定を構成します。",
|
"description": "アプリケーション設定を構成します。",
|
||||||
|
"apiKeysModels": "API キーとモデル",
|
||||||
|
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
|
||||||
"accessCode": "アクセスコード",
|
"accessCode": "アクセスコード",
|
||||||
"accessCodePlaceholder": "アクセスコードを入力",
|
"accessCodePlaceholder": "アクセスコードを入力",
|
||||||
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
||||||
@@ -98,14 +106,34 @@
|
|||||||
"switchTo": "切り替え",
|
"switchTo": "切り替え",
|
||||||
"minimal": "ミニマル",
|
"minimal": "ミニマル",
|
||||||
"sketch": "スケッチ",
|
"sketch": "スケッチ",
|
||||||
"closeProtection": "ページ離脱確認",
|
|
||||||
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
|
||||||
"diagramStyle": "ダイアグラムスタイル",
|
"diagramStyle": "ダイアグラムスタイル",
|
||||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||||
|
"sendShortcut": "送信ショートカット",
|
||||||
|
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
||||||
|
"enterToSend": "Enterで送信",
|
||||||
|
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
||||||
"diagramActions": "ダイアグラム操作",
|
"diagramActions": "ダイアグラム操作",
|
||||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
"download": "ダウンロード"
|
"download": "ダウンロード",
|
||||||
|
"proxy": "プロキシ設定",
|
||||||
|
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
|
||||||
|
"httpProxy": "HTTP プロキシ",
|
||||||
|
"httpsProxy": "HTTPS プロキシ",
|
||||||
|
"applyProxy": "適用",
|
||||||
|
"proxyApplied": "プロキシ設定が適用されました",
|
||||||
|
"diagramValidation": "ダイアグラム検証(実験的)",
|
||||||
|
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"disabled": "無効",
|
||||||
|
"customSystemMessage": "カスタムシステムメッセージ",
|
||||||
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
|
"panelVisibility": "ロビーパネル",
|
||||||
|
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||||
|
"showRecentChats": "最近のチャット",
|
||||||
|
"showMyTemplates": "マイテンプレート",
|
||||||
|
"showQuickExamples": "クイック例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -116,8 +144,10 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像"
|
"svg": "SVG 画像",
|
||||||
}
|
"xmlsvg": "編集可能 SVG"
|
||||||
|
},
|
||||||
|
"savedSuccessfully": "保存完了!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "ダイアグラム履歴",
|
"title": "ダイアグラム履歴",
|
||||||
@@ -162,8 +192,11 @@
|
|||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
|
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
|
||||||
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
||||||
"configModel": "APIキーを使用",
|
"configModel": "APIキーを使用",
|
||||||
@@ -185,6 +218,15 @@
|
|||||||
"chars": "文字",
|
"chars": "文字",
|
||||||
"removeFile": "ファイルを削除"
|
"removeFile": "ファイルを削除"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "URLからコンテンツを抽出",
|
||||||
|
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
||||||
|
"Extracting": "抽出中...",
|
||||||
|
"extract": "抽出",
|
||||||
|
"Cancel": "キャンセル",
|
||||||
|
"enterUrl": "URLを入力してください",
|
||||||
|
"invalidFormat": "無効なURL形式です"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "考え中...",
|
"thinking": "考え中...",
|
||||||
"thoughtFor": "{duration} 秒考えました",
|
"thoughtFor": "{duration} 秒考えました",
|
||||||
@@ -212,6 +254,40 @@
|
|||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "チャット履歴",
|
||||||
|
"newChat": "新しいチャット",
|
||||||
|
"empty": "チャット履歴はまだありません",
|
||||||
|
"emptyHint": "会話を始めてください",
|
||||||
|
"today": "今日",
|
||||||
|
"yesterday": "昨日",
|
||||||
|
"thisWeek": "今週",
|
||||||
|
"earlier": "それ以前",
|
||||||
|
"deleteTitle": "このチャットを削除しますか?",
|
||||||
|
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
|
||||||
|
"recentChats": "最近のチャット",
|
||||||
|
"justNow": "たった今",
|
||||||
|
"searchPlaceholder": "チャットを検索...",
|
||||||
|
"noResults": "チャットが見つかりません"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"title": "ダイアグラムを検証",
|
||||||
|
"capturing": "キャプチャ中",
|
||||||
|
"validating": "検証中",
|
||||||
|
"validatingWithAttempt": "検証中 ({attempt}/{max})",
|
||||||
|
"valid": "有効",
|
||||||
|
"validWithWarnings": "有効(警告あり)",
|
||||||
|
"issuesFound": "問題が見つかりました",
|
||||||
|
"error": "エラー",
|
||||||
|
"skipped": "スキップ",
|
||||||
|
"capturedScreenshot": "キャプチャした画像:",
|
||||||
|
"issuesFoundLabel": "検出された問題:",
|
||||||
|
"suggestions": "提案:",
|
||||||
|
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
|
||||||
|
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
|
||||||
|
"improveWithSuggestions": "提案で改善",
|
||||||
|
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AIモデル設定",
|
"title": "AIモデル設定",
|
||||||
"description": "複数のAIプロバイダーとモデルを設定",
|
"description": "複数のAIプロバイダーとモデルを設定",
|
||||||
@@ -244,7 +320,9 @@
|
|||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
|
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
|
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
"allAdded": "すべて追加済み",
|
"allAdded": "すべて追加済み",
|
||||||
@@ -269,10 +347,59 @@
|
|||||||
"noModelsFound": "モデルが見つかりません。",
|
"noModelsFound": "モデルが見つかりません。",
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"serverDefault": "サーバーデフォルト",
|
"serverDefault": "サーバーデフォルト",
|
||||||
|
"serverModels": "サーバーモデル",
|
||||||
|
"userModels": "ユーザーモデル",
|
||||||
"configureModels": "モデルを設定...",
|
"configureModels": "モデルを設定...",
|
||||||
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません"
|
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||||
|
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "マイテンプレート",
|
||||||
|
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
|
||||||
|
"emptyTitle": "テンプレートがありません",
|
||||||
|
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
|
||||||
|
"createFirst": "最初のテンプレートを作成",
|
||||||
|
"neverUsed": "未使用",
|
||||||
|
"usedCount": "{count} 回使用",
|
||||||
|
"myTemplates": "マイテンプレート",
|
||||||
|
"createTitle": "テンプレート作成",
|
||||||
|
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
|
||||||
|
"promptLabel": "プロンプト",
|
||||||
|
"promptPlaceholder": "作成するダイアグラムを説明してください...",
|
||||||
|
"promptRequired": "プロンプトは必須です",
|
||||||
|
"titleLabel": "タイトル",
|
||||||
|
"titlePlaceholder": "タイトルを入力",
|
||||||
|
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
|
||||||
|
"descriptionLabel": "説明",
|
||||||
|
"descriptionPlaceholder": "このテンプレートの説明を追加",
|
||||||
|
"pinnedLabel": "テンプレートをピン留め",
|
||||||
|
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
|
||||||
|
"createButton": "テンプレート作成",
|
||||||
|
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
|
||||||
|
"editTitle": "テンプレート編集",
|
||||||
|
"editDescription": "テンプレートの内容と設定を更新します。",
|
||||||
|
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
|
||||||
|
"duplicate": "複製",
|
||||||
|
"copySuffix": "(コピー)",
|
||||||
|
"deleteTitle": "このテンプレートを削除しますか?",
|
||||||
|
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
|
||||||
|
"confirmSendTitle": "現在の入力を置き換えますか?",
|
||||||
|
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
|
||||||
|
"confirmSendButton": "テンプレートを送信",
|
||||||
|
"searchPlaceholder": "テンプレートを検索...",
|
||||||
|
"searchNoResults": "検索に一致するテンプレートがありません",
|
||||||
|
"pin": "上部にピン留め",
|
||||||
|
"unpin": "ピン留め解除",
|
||||||
|
"saveAsTemplate": "テンプレートとして保存",
|
||||||
|
"exportTemplates": "テンプレートをエクスポート",
|
||||||
|
"importTemplates": "テンプレートをインポート",
|
||||||
|
"exportEmpty": "エクスポートするテンプレートがありません",
|
||||||
|
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
|
||||||
|
"importNoFile": "JSON ファイルを選択してください",
|
||||||
|
"importFailed": "インポートに失敗しました:{error}",
|
||||||
|
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
405
lib/i18n/dictionaries/zh-Hant.json
Normal file
405
lib/i18n/dictionaries/zh-Hant.json
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "儲存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"close": "關閉",
|
||||||
|
"confirm": "確認",
|
||||||
|
"clear": "清除",
|
||||||
|
"edit": "編輯",
|
||||||
|
"delete": "刪除",
|
||||||
|
"loading": "載入中...",
|
||||||
|
"new": "新建"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"about": "關於",
|
||||||
|
"editor": "編輯器",
|
||||||
|
"newChat": "開始新對話",
|
||||||
|
"github": "GitHub",
|
||||||
|
"settings": "設定",
|
||||||
|
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
|
||||||
|
"showPanel": "顯示聊天面板 (Ctrl+B)",
|
||||||
|
"aiChat": "AI 聊天"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"useServerDefault": "使用伺服器預設值",
|
||||||
|
"openai": "OpenAI",
|
||||||
|
"anthropic": "Anthropic",
|
||||||
|
"google": "Google",
|
||||||
|
"azure": "Azure OpenAI",
|
||||||
|
"openrouter": "OpenRouter",
|
||||||
|
"deepseek": "DeepSeek",
|
||||||
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"placeholder": "描述您的圖表或上傳檔案...",
|
||||||
|
"send": "傳送",
|
||||||
|
"stopGeneration": "停止產生",
|
||||||
|
"sendMessage": "傳送訊息",
|
||||||
|
"clearConversation": "清除對話",
|
||||||
|
"diagramHistory": "圖表歷史",
|
||||||
|
"saveDiagram": "儲存圖表",
|
||||||
|
"uploadFile": "上傳檔案(圖片、PDF、文字)",
|
||||||
|
"minimalStyle": "簡約",
|
||||||
|
"styledMode": "精緻",
|
||||||
|
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
|
||||||
|
"regenerate": "重新產生回應",
|
||||||
|
"copyResponse": "複製回應",
|
||||||
|
"copied": "已複製!",
|
||||||
|
"failedToCopy": "複製失敗",
|
||||||
|
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
|
||||||
|
"goodResponse": "有幫助",
|
||||||
|
"badResponse": "無幫助",
|
||||||
|
"clickToEdit": "點擊編輯",
|
||||||
|
"editMessage": "編輯訊息",
|
||||||
|
"saveAndSubmit": "儲存並提交",
|
||||||
|
"ExtractURL": "從 URL 擷取"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"title": "用 AI 建立圖表",
|
||||||
|
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
|
||||||
|
"quickExamples": "快速範例",
|
||||||
|
"paperToDiagram": "文件轉圖表",
|
||||||
|
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
|
||||||
|
"animatedDiagram": "動畫圖表",
|
||||||
|
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
|
||||||
|
"awsArchitecture": "AWS 架構",
|
||||||
|
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
|
||||||
|
"replicateFlowchart": "複製流程圖",
|
||||||
|
"replicateDescription": "上傳並複製現有流程圖",
|
||||||
|
"creativeDrawing": "創意繪圖",
|
||||||
|
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||||
|
"cachedNote": "範例已快取,可即時回應",
|
||||||
|
"mcpServer": "MCP 伺服器",
|
||||||
|
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "設定",
|
||||||
|
"description": "配置您的應用程式設定。",
|
||||||
|
"apiKeysModels": "API 金鑰和模型",
|
||||||
|
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
|
||||||
|
"accessCode": "存取碼",
|
||||||
|
"accessCodePlaceholder": "輸入存取碼",
|
||||||
|
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
||||||
|
"aiProvider": "AI 提供商設定",
|
||||||
|
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
|
||||||
|
"provider": "提供商",
|
||||||
|
"modelId": "模型 ID",
|
||||||
|
"apiKey": "API 金鑰",
|
||||||
|
"apiKeyPlaceholder": "您的 API 金鑰",
|
||||||
|
"baseUrl": "基礎 URL(可選)",
|
||||||
|
"customEndpoint": "自訂端點 URL",
|
||||||
|
"overrides": "覆寫",
|
||||||
|
"clearSettings": "清除設定",
|
||||||
|
"useServerDefault": "使用伺服器預設值",
|
||||||
|
"language": "語言",
|
||||||
|
"languageDescription": "選擇介面語言。",
|
||||||
|
"theme": "主題",
|
||||||
|
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||||
|
"drawioStyle": "DrawIO 樣式",
|
||||||
|
"drawioStyleDescription": "畫布樣式:",
|
||||||
|
"switchTo": "切換到",
|
||||||
|
"minimal": "簡約",
|
||||||
|
"sketch": "草圖",
|
||||||
|
"diagramStyle": "圖表樣式",
|
||||||
|
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||||
|
"sendShortcut": "傳送快捷鍵",
|
||||||
|
"sendShortcutDescription": "選擇傳送訊息的方式。",
|
||||||
|
"enterToSend": "Enter 傳送",
|
||||||
|
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
|
||||||
|
"diagramActions": "圖表操作",
|
||||||
|
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
|
||||||
|
"history": "歷史紀錄",
|
||||||
|
"download": "下載",
|
||||||
|
"proxy": "代理設定",
|
||||||
|
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
|
||||||
|
"httpProxy": "HTTP 代理",
|
||||||
|
"httpsProxy": "HTTPS 代理",
|
||||||
|
"applyProxy": "套用",
|
||||||
|
"proxyApplied": "代理設定已套用",
|
||||||
|
"diagramValidation": "圖表驗證(實驗性)",
|
||||||
|
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"disabled": "已停用",
|
||||||
|
"customSystemMessage": "自訂系統訊息",
|
||||||
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
|
"panelVisibility": "大廳面板",
|
||||||
|
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的範本",
|
||||||
|
"showQuickExamples": "快速範例"
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"title": "儲存圖表",
|
||||||
|
"description": "選擇格式和檔案名稱以儲存您的圖表。",
|
||||||
|
"format": "格式",
|
||||||
|
"filename": "檔案名稱",
|
||||||
|
"filenamePlaceholder": "輸入檔案名稱",
|
||||||
|
"formats": {
|
||||||
|
"drawio": "Draw.io XML",
|
||||||
|
"png": "PNG 圖片",
|
||||||
|
"svg": "SVG 圖片",
|
||||||
|
"xmlsvg": "可編輯 SVG"
|
||||||
|
},
|
||||||
|
"savedSuccessfully": "儲存成功!"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"title": "圖表歷史",
|
||||||
|
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
|
||||||
|
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
|
||||||
|
"version": "版本",
|
||||||
|
"restoreTo": "還原到版本 {version}?"
|
||||||
|
},
|
||||||
|
"dialogs": {
|
||||||
|
"clearTitle": "清除所有內容?",
|
||||||
|
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
|
||||||
|
"clearEverything": "清除所有內容",
|
||||||
|
"clearSuccess": "已開始新對話"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"maxFiles": "檔案太多。最多允許 {max} 個。",
|
||||||
|
"onlyMoreAllowed": "只能再新增 {slots} 個檔案",
|
||||||
|
"fileExceeds": "「{name}」大小為 {size}(超過 {max}MB)",
|
||||||
|
"unsupportedType": "「{name}」不是支援的檔案類型",
|
||||||
|
"filesRejected": "{count} 個檔案被拒絕:",
|
||||||
|
"andMore": "...還有 {count} 個",
|
||||||
|
"invalidAccessCode": "無效或缺少存取碼。請在設定中配置。",
|
||||||
|
"networkError": "網路錯誤。請檢查您的連線。",
|
||||||
|
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
||||||
|
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
||||||
|
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
|
||||||
|
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
|
||||||
|
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
|
||||||
|
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
||||||
|
"failedToSave": "無法儲存訊息到 localStorage",
|
||||||
|
"failedToRestore": "無法從 localStorage 還原",
|
||||||
|
"failedToPersist": "卸載前無法持久化狀態",
|
||||||
|
"failedToExport": "取得圖表資料時出錯",
|
||||||
|
"failedToLoadExample": "載入範例圖片時出錯",
|
||||||
|
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||||
|
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"dailyLimit": "已達每日配額",
|
||||||
|
"tokenLimit": "已達每日令牌限制",
|
||||||
|
"tpmLimit": "速率限制",
|
||||||
|
"tpmMessage": "請求過多。請稍等片刻。",
|
||||||
|
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
|
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
|
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||||
|
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
|
||||||
|
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||||
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
||||||
|
"configModel": "使用您的金鑰",
|
||||||
|
"selfHost": "自行託管",
|
||||||
|
"sponsor": "贊助",
|
||||||
|
"learnMore": "了解更多 →",
|
||||||
|
"usedOf": "{used}/{limit}"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"generateDiagram": "產生圖表",
|
||||||
|
"editDiagram": "編輯圖表",
|
||||||
|
"appendDiagram": "繼續圖表",
|
||||||
|
"complete": "完成",
|
||||||
|
"error": "錯誤",
|
||||||
|
"truncated": "已截斷"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"reading": "讀取中...",
|
||||||
|
"chars": "字元",
|
||||||
|
"removeFile": "移除檔案"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "從 URL 擷取內容",
|
||||||
|
"description": "貼上 URL 以擷取和分析其內容",
|
||||||
|
"Extracting": "擷取中...",
|
||||||
|
"extract": "擷取",
|
||||||
|
"Cancel": "取消",
|
||||||
|
"enterUrl": "請輸入 URL",
|
||||||
|
"invalidFormat": "URL 格式無效"
|
||||||
|
},
|
||||||
|
"reasoning": {
|
||||||
|
"thinking": "思考中...",
|
||||||
|
"thoughtFor": "思考了 {duration} 秒",
|
||||||
|
"thoughtBrief": "思考了幾秒鐘"
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"title": "開發:XML 串流模擬器",
|
||||||
|
"preset": "預設:",
|
||||||
|
"selectPreset": "選擇預設...",
|
||||||
|
"clear": "清除",
|
||||||
|
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
|
||||||
|
"interval": "間隔:",
|
||||||
|
"chars": "字元:",
|
||||||
|
"streaming": "串流傳輸中...",
|
||||||
|
"simulate": "模擬",
|
||||||
|
"stop": "停止",
|
||||||
|
"testQuotaToast": "測試配額提示",
|
||||||
|
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
|
||||||
|
"successMessage": "成功顯示圖表。"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"modelChange": "模型變更與用量限制",
|
||||||
|
"walletCrying": "(別名:我的錢包頂不住了)",
|
||||||
|
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
|
||||||
|
"contactMe": "聯絡我",
|
||||||
|
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
|
||||||
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "聊天歷史",
|
||||||
|
"newChat": "新對話",
|
||||||
|
"empty": "暫無聊天紀錄",
|
||||||
|
"emptyHint": "開始對話吧",
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天",
|
||||||
|
"thisWeek": "本週",
|
||||||
|
"earlier": "更早",
|
||||||
|
"deleteTitle": "刪除此對話?",
|
||||||
|
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
|
||||||
|
"recentChats": "最近對話",
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"searchPlaceholder": "搜尋對話...",
|
||||||
|
"noResults": "未找到對話"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "我的範本",
|
||||||
|
"subtitle": "快速建立圖表的個人提示庫",
|
||||||
|
"emptyTitle": "尚無範本",
|
||||||
|
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
|
||||||
|
"createFirst": "建立第一個範本",
|
||||||
|
"neverUsed": "未使用過",
|
||||||
|
"usedCount": "使用 {count} 次",
|
||||||
|
"myTemplates": "我的範本",
|
||||||
|
"createTitle": "建立範本",
|
||||||
|
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
|
||||||
|
"promptLabel": "提示",
|
||||||
|
"promptPlaceholder": "描述您想建立的圖表...",
|
||||||
|
"promptRequired": "提示為必填項",
|
||||||
|
"titleLabel": "標題",
|
||||||
|
"titlePlaceholder": "輸入標題",
|
||||||
|
"titleHint": "留空則使用提示的前 20 個字元",
|
||||||
|
"descriptionLabel": "描述",
|
||||||
|
"descriptionPlaceholder": "為此範本新增描述",
|
||||||
|
"pinnedLabel": "釘選範本",
|
||||||
|
"pinnedHint": "釘選的範本會顯示在清單頂部",
|
||||||
|
"createButton": "建立範本",
|
||||||
|
"createFailed": "建立範本失敗,請重試。",
|
||||||
|
"editTitle": "編輯範本",
|
||||||
|
"editDescription": "更新範本內容和設定。",
|
||||||
|
"updateFailed": "更新範本失敗,請重試。",
|
||||||
|
"duplicate": "複製",
|
||||||
|
"copySuffix": "(副本)",
|
||||||
|
"deleteTitle": "刪除此範本?",
|
||||||
|
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
|
||||||
|
"confirmSendTitle": "取代目前輸入?",
|
||||||
|
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
|
||||||
|
"confirmSendButton": "傳送範本",
|
||||||
|
"searchPlaceholder": "搜尋範本...",
|
||||||
|
"searchNoResults": "沒有符合的範本",
|
||||||
|
"pin": "置頂",
|
||||||
|
"unpin": "取消置頂",
|
||||||
|
"saveAsTemplate": "儲存為範本",
|
||||||
|
"exportTemplates": "匯出範本",
|
||||||
|
"importTemplates": "匯入範本",
|
||||||
|
"exportEmpty": "沒有可匯出的範本",
|
||||||
|
"exportSuccess": "成功匯出 {count} 個範本",
|
||||||
|
"importNoFile": "請選擇一個 JSON 檔案",
|
||||||
|
"importFailed": "匯入失敗:{error}",
|
||||||
|
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"title": "驗證圖表",
|
||||||
|
"capturing": "截圖中",
|
||||||
|
"validating": "驗證中",
|
||||||
|
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
|
||||||
|
"valid": "通過",
|
||||||
|
"validWithWarnings": "通過(有警告)",
|
||||||
|
"issuesFound": "發現問題",
|
||||||
|
"error": "錯誤",
|
||||||
|
"skipped": "已跳過",
|
||||||
|
"capturedScreenshot": "截圖預覽:",
|
||||||
|
"issuesFoundLabel": "發現的問題:",
|
||||||
|
"suggestions": "建議:",
|
||||||
|
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
|
||||||
|
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
|
||||||
|
"improveWithSuggestions": "根據建議改進",
|
||||||
|
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
|
||||||
|
},
|
||||||
|
"modelConfig": {
|
||||||
|
"title": "AI 模型配置",
|
||||||
|
"description": "配置多個 AI 提供商和模型",
|
||||||
|
"configure": "配置",
|
||||||
|
"addProvider": "新增提供商",
|
||||||
|
"addModel": "新增模型",
|
||||||
|
"modelId": "模型 ID",
|
||||||
|
"modelLabel": "顯示名稱",
|
||||||
|
"streaming": "啟用串流輸出",
|
||||||
|
"deleteProvider": "刪除提供商",
|
||||||
|
"deleteModel": "刪除模型",
|
||||||
|
"noModels": "尚未配置模型。新增模型以開始使用。",
|
||||||
|
"selectProvider": "選擇一個提供商或新增",
|
||||||
|
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
|
||||||
|
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
|
||||||
|
"test": "測試",
|
||||||
|
"validationError": "驗證失敗",
|
||||||
|
"addModelFirst": "請先新增至少一個模型以進行驗證",
|
||||||
|
"providers": "提供商",
|
||||||
|
"addProviderHint": "新增提供商即可開始使用",
|
||||||
|
"verified": "已驗證",
|
||||||
|
"configuration": "配置",
|
||||||
|
"displayName": "顯示名稱",
|
||||||
|
"awsAccessKeyId": "AWS 存取金鑰 ID",
|
||||||
|
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||||
|
"awsRegion": "AWS 區域",
|
||||||
|
"selectRegion": "選擇區域",
|
||||||
|
"apiKey": "API 金鑰",
|
||||||
|
"enterApiKey": "輸入您的 API 金鑰",
|
||||||
|
"enterSecretKey": "輸入您的 Secret Key",
|
||||||
|
"baseUrl": "基礎 URL",
|
||||||
|
"optional": "(可選)",
|
||||||
|
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||||
|
"customEndpoint": "自訂端點 URL",
|
||||||
|
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||||
|
"models": "模型",
|
||||||
|
"customModelId": "自訂模型 ID...",
|
||||||
|
"allAdded": "已全部新增",
|
||||||
|
"suggested": "推薦",
|
||||||
|
"noModelsConfigured": "尚未配置模型",
|
||||||
|
"modelIdEmpty": "模型 ID 不能為空",
|
||||||
|
"modelIdExists": "此模型 ID 已存在",
|
||||||
|
"configureProviders": "配置 AI 提供商",
|
||||||
|
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
|
||||||
|
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
||||||
|
"typeToConfirm": "輸入「{name}」以確認",
|
||||||
|
"typeProviderName": "輸入提供商名稱...",
|
||||||
|
"modelsConfiguredCount": "已配置 {count} 個模型",
|
||||||
|
"validationFailedCount": "{count} 個模型驗證失敗",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "刪除",
|
||||||
|
"clickToChange": "(點擊變更)",
|
||||||
|
"usingServerDefault": "使用伺服器預設模型",
|
||||||
|
"selectModel": "選擇模型",
|
||||||
|
"searchModels": "搜尋模型...",
|
||||||
|
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
|
||||||
|
"noModelsFound": "未找到模型。",
|
||||||
|
"default": "預設",
|
||||||
|
"serverDefault": "伺服器預設",
|
||||||
|
"serverModels": "伺服器模型",
|
||||||
|
"userModels": "使用者模型",
|
||||||
|
"configureModels": "配置模型...",
|
||||||
|
"onlyVerifiedShown": "僅顯示已驗證的模型",
|
||||||
|
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||||
|
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||||
|
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||||
|
"serverDefaultModel": "伺服器預設模型"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,18 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
"send": "发送",
|
"send": "发送",
|
||||||
"sending": "发送中...",
|
"stopGeneration": "停止生成",
|
||||||
"sendMessage": "发送消息",
|
"sendMessage": "发送消息",
|
||||||
"clearConversation": "清除对话",
|
"clearConversation": "清除对话",
|
||||||
"diagramHistory": "图表历史",
|
"diagramHistory": "图表历史",
|
||||||
@@ -51,7 +57,8 @@
|
|||||||
"badResponse": "无帮助",
|
"badResponse": "无帮助",
|
||||||
"clickToEdit": "点击编辑",
|
"clickToEdit": "点击编辑",
|
||||||
"editMessage": "编辑消息",
|
"editMessage": "编辑消息",
|
||||||
"saveAndSubmit": "保存并提交"
|
"saveAndSubmit": "保存并提交",
|
||||||
|
"ExtractURL": "从 URL 提取"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "用 AI 创建图表",
|
"title": "用 AI 创建图表",
|
||||||
@@ -69,12 +76,13 @@
|
|||||||
"creativeDescription": "绘制有趣且富有创意的内容",
|
"creativeDescription": "绘制有趣且富有创意的内容",
|
||||||
"cachedNote": "示例已缓存,可即时响应",
|
"cachedNote": "示例已缓存,可即时响应",
|
||||||
"mcpServer": "MCP 服务器",
|
"mcpServer": "MCP 服务器",
|
||||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||||
"preview": "预览"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"description": "配置您的应用程序设置。",
|
"description": "配置您的应用程序设置。",
|
||||||
|
"apiKeysModels": "API 密钥和模型",
|
||||||
|
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
|
||||||
"accessCode": "访问码",
|
"accessCode": "访问码",
|
||||||
"accessCodePlaceholder": "输入访问码",
|
"accessCodePlaceholder": "输入访问码",
|
||||||
"accessCodeDescription": "使用此应用程序需要访问码。",
|
"accessCodeDescription": "使用此应用程序需要访问码。",
|
||||||
@@ -98,14 +106,34 @@
|
|||||||
"switchTo": "切换到",
|
"switchTo": "切换到",
|
||||||
"minimal": "简约",
|
"minimal": "简约",
|
||||||
"sketch": "草图",
|
"sketch": "草图",
|
||||||
"closeProtection": "关闭确认",
|
|
||||||
"closeProtectionDescription": "离开页面时显示确认。",
|
|
||||||
"diagramStyle": "图表样式",
|
"diagramStyle": "图表样式",
|
||||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||||
|
"sendShortcut": "发送快捷键",
|
||||||
|
"sendShortcutDescription": "选择发送消息的方式。",
|
||||||
|
"enterToSend": "回车发送",
|
||||||
|
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
|
||||||
"diagramActions": "图表操作",
|
"diagramActions": "图表操作",
|
||||||
"diagramActionsDescription": "管理图表历史记录和导出",
|
"diagramActionsDescription": "管理图表历史记录和导出",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"download": "下载"
|
"download": "下载",
|
||||||
|
"proxy": "代理设置",
|
||||||
|
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
|
||||||
|
"httpProxy": "HTTP 代理",
|
||||||
|
"httpsProxy": "HTTPS 代理",
|
||||||
|
"applyProxy": "应用",
|
||||||
|
"proxyApplied": "代理设置已应用",
|
||||||
|
"diagramValidation": "图表验证(实验性)",
|
||||||
|
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"customSystemMessage": "自定义系统消息",
|
||||||
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
|
"panelVisibility": "大厅面板",
|
||||||
|
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的模板",
|
||||||
|
"showQuickExamples": "快速示例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -116,8 +144,10 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片"
|
"svg": "SVG 图片",
|
||||||
}
|
"xmlsvg": "可编辑 SVG"
|
||||||
|
},
|
||||||
|
"savedSuccessfully": "保存成功!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "图表历史",
|
"title": "图表历史",
|
||||||
@@ -162,8 +192,11 @@
|
|||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
||||||
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
|
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
|
||||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
||||||
"configModel": "使用您的密钥",
|
"configModel": "使用您的密钥",
|
||||||
@@ -185,6 +218,15 @@
|
|||||||
"chars": "字符",
|
"chars": "字符",
|
||||||
"removeFile": "移除文件"
|
"removeFile": "移除文件"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "从 URL 提取内容",
|
||||||
|
"description": "粘贴 URL 以提取和分析其内容",
|
||||||
|
"Extracting": "提取中...",
|
||||||
|
"extract": "提取",
|
||||||
|
"Cancel": "取消",
|
||||||
|
"enterUrl": "请输入 URL",
|
||||||
|
"invalidFormat": "URL 格式无效"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "思考中...",
|
"thinking": "思考中...",
|
||||||
"thoughtFor": "思考了 {duration} 秒",
|
"thoughtFor": "思考了 {duration} 秒",
|
||||||
@@ -212,6 +254,86 @@
|
|||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "聊天历史",
|
||||||
|
"newChat": "新对话",
|
||||||
|
"empty": "暂无聊天记录",
|
||||||
|
"emptyHint": "开始对话吧",
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天",
|
||||||
|
"thisWeek": "本周",
|
||||||
|
"earlier": "更早",
|
||||||
|
"deleteTitle": "删除此对话?",
|
||||||
|
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
|
||||||
|
"recentChats": "最近对话",
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"searchPlaceholder": "搜索对话...",
|
||||||
|
"noResults": "未找到对话"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "我的模板库",
|
||||||
|
"subtitle": "您的个人 prompt 库,快速创建图表",
|
||||||
|
"emptyTitle": "暂无模板",
|
||||||
|
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
|
||||||
|
"createFirst": "创建第一个模板",
|
||||||
|
"neverUsed": "未使用过",
|
||||||
|
"usedCount": "使用 {count} 次",
|
||||||
|
"myTemplates": "我的模板",
|
||||||
|
"createTitle": "创建模板",
|
||||||
|
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
|
||||||
|
"promptLabel": "提示词",
|
||||||
|
"promptPlaceholder": "描述您想要创建的图表...",
|
||||||
|
"promptRequired": "提示词不能为空",
|
||||||
|
"titleLabel": "标题",
|
||||||
|
"titlePlaceholder": "输入标题",
|
||||||
|
"titleHint": "留空则使用提示词前 20 个字符作为标题",
|
||||||
|
"descriptionLabel": "描述",
|
||||||
|
"descriptionPlaceholder": "为此模板添加描述",
|
||||||
|
"pinnedLabel": "置顶模板",
|
||||||
|
"pinnedHint": "置顶的模板会显示在列表顶部",
|
||||||
|
"createButton": "创建模板",
|
||||||
|
"createFailed": "创建模板失败,请重试。",
|
||||||
|
"editTitle": "编辑模板",
|
||||||
|
"editDescription": "更新模板内容和设置。",
|
||||||
|
"updateFailed": "更新模板失败,请重试。",
|
||||||
|
"duplicate": "复制",
|
||||||
|
"copySuffix": "(副本)",
|
||||||
|
"deleteTitle": "删除此模板?",
|
||||||
|
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
|
||||||
|
"confirmSendTitle": "替换当前输入?",
|
||||||
|
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
|
||||||
|
"confirmSendButton": "发送模板",
|
||||||
|
"searchPlaceholder": "搜索模板...",
|
||||||
|
"searchNoResults": "没有匹配的模板",
|
||||||
|
"pin": "置顶",
|
||||||
|
"unpin": "取消置顶",
|
||||||
|
"saveAsTemplate": "保存为模板",
|
||||||
|
"exportTemplates": "导出模板",
|
||||||
|
"importTemplates": "导入模板",
|
||||||
|
"exportEmpty": "没有可导出的模板",
|
||||||
|
"exportSuccess": "成功导出 {count} 个模板",
|
||||||
|
"importNoFile": "请选择一个 JSON 文件",
|
||||||
|
"importFailed": "导入失败:{error}",
|
||||||
|
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"title": "验证图表",
|
||||||
|
"capturing": "截图中",
|
||||||
|
"validating": "验证中",
|
||||||
|
"validatingWithAttempt": "验证中 ({attempt}/{max})",
|
||||||
|
"valid": "通过",
|
||||||
|
"validWithWarnings": "通过(有警告)",
|
||||||
|
"issuesFound": "发现问题",
|
||||||
|
"error": "错误",
|
||||||
|
"skipped": "已跳过",
|
||||||
|
"capturedScreenshot": "截图预览:",
|
||||||
|
"issuesFoundLabel": "发现的问题:",
|
||||||
|
"suggestions": "建议:",
|
||||||
|
"passedValidation": "图表通过视觉验证 - 未发现问题。",
|
||||||
|
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
|
||||||
|
"improveWithSuggestions": "根据建议改进",
|
||||||
|
"regenerateWithFeedback": "使用验证反馈重新生成图表"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI 模型配置",
|
"title": "AI 模型配置",
|
||||||
"description": "配置多个 AI 提供商和模型",
|
"description": "配置多个 AI 提供商和模型",
|
||||||
@@ -244,7 +366,9 @@
|
|||||||
"enterSecretKey": "输入您的 Secret Key",
|
"enterSecretKey": "输入您的 Secret Key",
|
||||||
"baseUrl": "基础 URL",
|
"baseUrl": "基础 URL",
|
||||||
"optional": "(可选)",
|
"optional": "(可选)",
|
||||||
|
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
|
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
"allAdded": "已全部添加",
|
"allAdded": "已全部添加",
|
||||||
@@ -269,10 +393,13 @@
|
|||||||
"noModelsFound": "未找到模型。",
|
"noModelsFound": "未找到模型。",
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
"serverDefault": "服务器默认",
|
"serverDefault": "服务器默认",
|
||||||
|
"serverModels": "服务器模型",
|
||||||
|
"userModels": "用户模型",
|
||||||
"configureModels": "配置模型...",
|
"configureModels": "配置模型...",
|
||||||
"onlyVerifiedShown": "仅显示已验证的模型",
|
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||||
"showUnvalidatedModels": "显示未验证的模型",
|
"showUnvalidatedModels": "显示未验证的模型",
|
||||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||||
"unvalidatedModelWarning": "此模型尚未验证"
|
"unvalidatedModelWarning": "此模型尚未验证",
|
||||||
|
"serverDefaultModel": "服务器默认模型"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
lib/server-model-config.ts
Normal file
155
lib/server-model-config.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
import { PROVIDER_INFO } from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
||||||
|
.string()
|
||||||
|
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
|
||||||
|
message: "Invalid provider name",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ServerProviderSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
provider: ProviderNameSchema,
|
||||||
|
models: z.array(z.string().min(1)),
|
||||||
|
// Optional: custom environment variable name(s) for API key
|
||||||
|
// Can be a single string or array of strings for load balancing
|
||||||
|
// e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"]
|
||||||
|
apiKeyEnv: z
|
||||||
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
||||||
|
.optional(),
|
||||||
|
// Optional: custom environment variable name for base URL
|
||||||
|
baseUrlEnv: z.string().min(1).optional(),
|
||||||
|
// Optional: mark the first model in this provider as the default
|
||||||
|
default: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ServerModelsConfigSchema = z.object({
|
||||||
|
providers: z.array(ServerProviderSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
|
||||||
|
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
|
||||||
|
|
||||||
|
export interface FlattenedServerModel {
|
||||||
|
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
|
||||||
|
modelId: string
|
||||||
|
provider: ProviderName
|
||||||
|
providerLabel: string
|
||||||
|
isDefault: boolean
|
||||||
|
// Custom env var name(s) for API key (optional)
|
||||||
|
// Can be a single string or array of strings for load balancing
|
||||||
|
apiKeyEnv?: string | string[]
|
||||||
|
baseUrlEnv?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert provider name to URL-safe slug for use in model ID
|
||||||
|
* e.g., "OpenAI Production" → "openai-production"
|
||||||
|
*/
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath(): string {
|
||||||
|
const custom = process.env.AI_MODELS_CONFIG_PATH
|
||||||
|
if (custom && custom.trim().length > 0) return custom
|
||||||
|
return path.join(process.cwd(), "ai-models.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||||
|
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
||||||
|
const envConfig = process.env.AI_MODELS_CONFIG
|
||||||
|
if (envConfig && envConfig.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(envConfig)
|
||||||
|
return ServerModelsConfigSchema.parse(json)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: ai-models.json file
|
||||||
|
const configPath = getConfigPath()
|
||||||
|
try {
|
||||||
|
const jsonStr = await fs.readFile(configPath, "utf8")
|
||||||
|
const json = JSON.parse(jsonStr)
|
||||||
|
return ServerModelsConfigSchema.parse(json)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ENOENT") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"[server-model-config] Failed to load ai-models.json:",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFlattenedServerModels(): Promise<
|
||||||
|
FlattenedServerModel[]
|
||||||
|
> {
|
||||||
|
const cfg = await loadRawServerModelsConfig()
|
||||||
|
if (!cfg) return []
|
||||||
|
|
||||||
|
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
|
||||||
|
const defaultModelId = process.env.AI_MODEL
|
||||||
|
|
||||||
|
const flattened: FlattenedServerModel[] = []
|
||||||
|
|
||||||
|
for (const p of cfg.providers) {
|
||||||
|
const providerLabel =
|
||||||
|
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
|
||||||
|
|
||||||
|
// Use slugified name for unique ID (supports multiple API keys per provider)
|
||||||
|
const nameSlug = slugify(p.name)
|
||||||
|
|
||||||
|
for (const modelId of p.models) {
|
||||||
|
const id = `server:${nameSlug}:${modelId}`
|
||||||
|
|
||||||
|
// Default model priority:
|
||||||
|
// 1. From ai-models.json: first model of provider with default: true
|
||||||
|
// 2. From env vars: AI_MODEL matches (legacy behavior)
|
||||||
|
const isDefault =
|
||||||
|
(p.default === true && modelId === p.models[0]) ||
|
||||||
|
(!!defaultModelId &&
|
||||||
|
modelId === defaultModelId &&
|
||||||
|
(!defaultProvider || defaultProvider === p.provider))
|
||||||
|
|
||||||
|
flattened.push({
|
||||||
|
id,
|
||||||
|
modelId,
|
||||||
|
provider: p.provider,
|
||||||
|
providerLabel,
|
||||||
|
isDefault,
|
||||||
|
apiKeyEnv: p.apiKeyEnv,
|
||||||
|
baseUrlEnv: p.baseUrlEnv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
|
||||||
|
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
|
||||||
|
*/
|
||||||
|
export async function findServerModelById(
|
||||||
|
modelId: string,
|
||||||
|
): Promise<FlattenedServerModel | null> {
|
||||||
|
if (!modelId.startsWith("server:")) return null
|
||||||
|
|
||||||
|
const models = await loadFlattenedServerModels()
|
||||||
|
return models.find((m) => m.id === modelId) || null
|
||||||
|
}
|
||||||
430
lib/session-storage.ts
Normal file
430
lib/session-storage.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import type { Template } from "./template-storage"
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const DB_NAME = "next-ai-drawio"
|
||||||
|
const DB_VERSION = 2
|
||||||
|
const STORE_NAME = "sessions"
|
||||||
|
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||||
|
const MAX_SESSIONS = 50
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messages: StoredMessage[]
|
||||||
|
xmlSnapshots: [number, string][]
|
||||||
|
diagramXml: string
|
||||||
|
thumbnailDataUrl?: string // Small PNG preview of the diagram
|
||||||
|
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredMessage {
|
||||||
|
id: string
|
||||||
|
role: "user" | "assistant" | "system"
|
||||||
|
parts: Array<{ type: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messageCount: number
|
||||||
|
hasDiagram: boolean
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSessionDB extends DBSchema {
|
||||||
|
sessions: {
|
||||||
|
key: string
|
||||||
|
value: ChatSession
|
||||||
|
indexes: { "by-updated": number }
|
||||||
|
}
|
||||||
|
templates: {
|
||||||
|
key: string
|
||||||
|
value: Template
|
||||||
|
indexes: {
|
||||||
|
"by-updated": number
|
||||||
|
"by-pinned": number
|
||||||
|
"by-run-count": number
|
||||||
|
"by-last-used": number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database singleton
|
||||||
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||||
|
const resetDBPromise = () => {
|
||||||
|
dbPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClosingError = (error: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "InvalidStateError" &&
|
||||||
|
/closing/i.test(error.message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDB = async <T>(
|
||||||
|
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
} catch (error) {
|
||||||
|
if (isClosingError(error)) {
|
||||||
|
resetDBPromise()
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db, oldVersion) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "id",
|
||||||
|
})
|
||||||
|
store.createIndex("by-updated", "updatedAt")
|
||||||
|
}
|
||||||
|
// Version 2: templates store (added by template-storage.ts)
|
||||||
|
// Note: We also need to include this here to ensure the upgrade
|
||||||
|
// callback properly handles all migrations when opening from this file
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
// Check if templates store already exists (created by template-storage.ts)
|
||||||
|
if (!db.objectStoreNames.contains("templates")) {
|
||||||
|
const templateStore = db.createObjectStore(
|
||||||
|
"templates",
|
||||||
|
{
|
||||||
|
keyPath: "id",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
templateStore.createIndex("by-updated", "updatedAt")
|
||||||
|
templateStore.createIndex("by-pinned", "pinned")
|
||||||
|
templateStore.createIndex("by-run-count", "runCount")
|
||||||
|
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
resetDBPromise()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dbPromise
|
||||||
|
.then((db) => {
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resetDBPromise()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IndexedDB is available
|
||||||
|
export function isIndexedDBAvailable(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
return "indexedDB" in window && window.indexedDB !== null
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IndexedDB is actually usable (not just present).
|
||||||
|
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
|
||||||
|
// that other code depends on.
|
||||||
|
export async function isIndexedDBUsable(): Promise<boolean> {
|
||||||
|
if (!isIndexedDBAvailable()) return false
|
||||||
|
try {
|
||||||
|
await getDB()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD Operations
|
||||||
|
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||||
|
if (!isIndexedDBAvailable()) return []
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
|
const index = tx.store.index("by-updated")
|
||||||
|
const metadata: SessionMetadata[] = []
|
||||||
|
|
||||||
|
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||||
|
let cursor = await index.openCursor(null, "prev") // newest first
|
||||||
|
while (cursor) {
|
||||||
|
const s = cursor.value
|
||||||
|
metadata.push({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
messageCount: s.messages.length,
|
||||||
|
hasDiagram:
|
||||||
|
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||||
|
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||||
|
})
|
||||||
|
cursor = await cursor.continue()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session metadata:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||||
|
if (!isIndexedDBAvailable()) return false
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
// Handle quota exceeded
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "QuotaExceededError"
|
||||||
|
) {
|
||||||
|
console.warn("Storage quota exceeded, deleting oldest session...")
|
||||||
|
await deleteOldestSession()
|
||||||
|
// Retry once
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error(
|
||||||
|
"Failed to save session after cleanup:",
|
||||||
|
retryError,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Failed to save session:", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.delete(STORE_NAME, id)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionCount(): Promise<number> {
|
||||||
|
if (!isIndexedDBAvailable()) return 0
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
return await db.count(STORE_NAME)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session count:", error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
|
const index = tx.store.index("by-updated")
|
||||||
|
const cursor = await index.openCursor()
|
||||||
|
if (cursor) {
|
||||||
|
await cursor.delete()
|
||||||
|
}
|
||||||
|
await tx.done
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete oldest session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max sessions limit
|
||||||
|
export async function enforceSessionLimit(): Promise<void> {
|
||||||
|
const count = await getSessionCount()
|
||||||
|
if (count > MAX_SESSIONS) {
|
||||||
|
const toDelete = count - MAX_SESSIONS
|
||||||
|
for (let i = 0; i < toDelete; i++) {
|
||||||
|
await deleteOldestSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Create a new empty session
|
||||||
|
export function createEmptySession(): ChatSession {
|
||||||
|
return {
|
||||||
|
id: nanoid(),
|
||||||
|
title: "New Chat",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
xmlSnapshots: [],
|
||||||
|
diagramXml: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract title from first user message (truncated to reasonable length)
|
||||||
|
const MAX_TITLE_LENGTH = 100
|
||||||
|
|
||||||
|
export function extractTitle(messages: StoredMessage[]): string {
|
||||||
|
const firstUserMessage = messages.find((m) => m.role === "user")
|
||||||
|
if (!firstUserMessage) return "New Chat"
|
||||||
|
|
||||||
|
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
|
||||||
|
if (!textPart || typeof textPart.text !== "string") return "New Chat"
|
||||||
|
|
||||||
|
const text = textPart.text.trim()
|
||||||
|
if (!text) return "New Chat"
|
||||||
|
|
||||||
|
// Truncate long titles
|
||||||
|
if (text.length > MAX_TITLE_LENGTH) {
|
||||||
|
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Sanitize UIMessage to StoredMessage
|
||||||
|
export function sanitizeMessage(message: unknown): StoredMessage | null {
|
||||||
|
if (!message || typeof message !== "object") return null
|
||||||
|
|
||||||
|
const msg = message as Record<string, unknown>
|
||||||
|
if (!msg.id || !msg.role) return null
|
||||||
|
|
||||||
|
const role = msg.role as string
|
||||||
|
if (!["user", "assistant", "system"].includes(role)) return null
|
||||||
|
|
||||||
|
// Extract parts, removing streaming state artifacts
|
||||||
|
let parts: Array<{ type: string; [key: string]: unknown }> = []
|
||||||
|
if (Array.isArray(msg.parts)) {
|
||||||
|
parts = msg.parts.map((part: unknown) => {
|
||||||
|
if (!part || typeof part !== "object") return { type: "unknown" }
|
||||||
|
const p = part as Record<string, unknown>
|
||||||
|
// Remove streaming-related fields
|
||||||
|
const { isStreaming, streamingState, ...cleanPart } = p
|
||||||
|
return cleanPart as { type: string; [key: string]: unknown }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id as string,
|
||||||
|
role: role as "user" | "assistant" | "system",
|
||||||
|
parts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
|
||||||
|
return messages
|
||||||
|
.map(sanitizeMessage)
|
||||||
|
.filter((m): m is StoredMessage => m !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration from localStorage
|
||||||
|
export async function migrateFromLocalStorage(): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
if (localStorage.getItem(MIGRATION_FLAG)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
|
||||||
|
const savedSnapshots = localStorage.getItem(
|
||||||
|
"next-ai-draw-io-xml-snapshots",
|
||||||
|
)
|
||||||
|
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
|
||||||
|
|
||||||
|
let newSessionId: string | null = null
|
||||||
|
let migrationSucceeded = false
|
||||||
|
|
||||||
|
if (savedMessages) {
|
||||||
|
const messages = JSON.parse(savedMessages)
|
||||||
|
if (Array.isArray(messages) && messages.length > 0) {
|
||||||
|
const sanitized = sanitizeMessages(messages)
|
||||||
|
const session: ChatSession = {
|
||||||
|
id: nanoid(),
|
||||||
|
title: extractTitle(sanitized),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: sanitized,
|
||||||
|
xmlSnapshots: savedSnapshots
|
||||||
|
? JSON.parse(savedSnapshots)
|
||||||
|
: [],
|
||||||
|
diagramXml: savedXml || "",
|
||||||
|
}
|
||||||
|
const saved = await saveSession(session)
|
||||||
|
if (saved) {
|
||||||
|
// Verify the session was actually written
|
||||||
|
const verified = await getSession(session.id)
|
||||||
|
if (verified) {
|
||||||
|
newSessionId = session.id
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty array or invalid data - nothing to migrate, mark as success
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data to migrate - mark as success
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clean up old data if migration succeeded
|
||||||
|
if (migrationSucceeded) {
|
||||||
|
localStorage.setItem(MIGRATION_FLAG, "true")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-messages")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-diagram-xml")
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Migration to IndexedDB failed - keeping localStorage data for retry",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSessionId
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Migration failed:", error)
|
||||||
|
// Don't mark as migrated - allow retry on next load
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/ssrf-protection.ts
Normal file
63
lib/ssrf-protection.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* SSRF (Server-Side Request Forgery) protection utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL points to private/internal network
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
|
*/
|
||||||
|
export 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)
|
||||||
|
if (a === 10) return true // 10.0.0.0/8
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||||
|
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||||
|
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return true // Invalid URL - block it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether private URLs are allowed (defaults to true)
|
||||||
|
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
||||||
|
*/
|
||||||
|
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
// Centralized localStorage keys
|
// Centralized localStorage keys for quota tracking and settings
|
||||||
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
|
// Chat data is now stored in IndexedDB via session-storage.ts
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
// Chat data
|
|
||||||
messages: "next-ai-draw-io-messages",
|
|
||||||
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
|
|
||||||
diagramXml: "next-ai-draw-io-diagram-xml",
|
|
||||||
sessionId: "next-ai-draw-io-session-id",
|
|
||||||
|
|
||||||
// Quota tracking
|
// Quota tracking
|
||||||
requestCount: "next-ai-draw-io-request-count",
|
requestCount: "next-ai-draw-io-request-count",
|
||||||
requestDate: "next-ai-draw-io-request-date",
|
requestDate: "next-ai-draw-io-request-date",
|
||||||
@@ -18,7 +12,6 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
accessCode: "next-ai-draw-io-access-code",
|
accessCode: "next-ai-draw-io-access-code",
|
||||||
closeProtection: "next-ai-draw-io-close-protection",
|
|
||||||
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
aiProvider: "next-ai-draw-io-ai-provider",
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
@@ -28,4 +21,18 @@ export const STORAGE_KEYS = {
|
|||||||
// Multi-model configuration
|
// Multi-model configuration
|
||||||
modelConfigs: "next-ai-draw-io-model-configs",
|
modelConfigs: "next-ai-draw-io-model-configs",
|
||||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||||
|
|
||||||
|
// Chat input preferences
|
||||||
|
sendShortcut: "next-ai-draw-io-send-shortcut",
|
||||||
|
|
||||||
|
// Diagram validation
|
||||||
|
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
|
||||||
|
|
||||||
|
// Custom system message
|
||||||
|
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
||||||
|
|
||||||
|
// Panel visibility
|
||||||
|
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
||||||
|
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
||||||
|
showQuickExamples: "next-ai-draw-io-show-quick-examples",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
|
|||||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||||
|
ALWAYS respond in the same language as the user's last message.
|
||||||
|
|
||||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||||
@@ -50,9 +51,9 @@ parameters: {
|
|||||||
}
|
}
|
||||||
---Tool4---
|
---Tool4---
|
||||||
tool name: get_shape_library
|
tool name: get_shape_library
|
||||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
|
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||||
parameters: {
|
parameters: {
|
||||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
|
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||||
}
|
}
|
||||||
---End of tools---
|
---End of tools---
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ IMPORTANT: Choose the right tool:
|
|||||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||||
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
|
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||||
|
|
||||||
Core capabilities:
|
Core capabilities:
|
||||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||||
@@ -91,7 +92,7 @@ Note that:
|
|||||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||||
- Return XML only via tool calls, never in text responses.
|
- Return XML only via tool calls, never in text responses.
|
||||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
|
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||||
|
|
||||||
When using edit_diagram tool:
|
When using edit_diagram tool:
|
||||||
|
|||||||
437
lib/template-storage.ts
Normal file
437
lib/template-storage.ts
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const DB_NAME = "next-ai-drawio-templates"
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = "templates"
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Template {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
prompt: string
|
||||||
|
description?: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
clickCount: number
|
||||||
|
runCount: number
|
||||||
|
lastUsedAt: number
|
||||||
|
pinned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateCreateInput = Pick<Template, "prompt"> &
|
||||||
|
Partial<
|
||||||
|
Omit<
|
||||||
|
Template,
|
||||||
|
| "id"
|
||||||
|
| "createdAt"
|
||||||
|
| "updatedAt"
|
||||||
|
| "clickCount"
|
||||||
|
| "runCount"
|
||||||
|
| "lastUsedAt"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
interface TemplateDB extends DBSchema {
|
||||||
|
templates: {
|
||||||
|
key: string
|
||||||
|
value: Template
|
||||||
|
indexes: {
|
||||||
|
"by-updated": number
|
||||||
|
"by-pinned": number
|
||||||
|
"by-run-count": number
|
||||||
|
"by-last-used": number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default title: first 20 chars of trimmed prompt, with ellipsis if truncated
|
||||||
|
const DEFAULT_TITLE_MAX_LENGTH = 20
|
||||||
|
|
||||||
|
export function generateDefaultTitle(prompt: string): string {
|
||||||
|
const trimmed = prompt.trim()
|
||||||
|
if (trimmed.length <= DEFAULT_TITLE_MAX_LENGTH) return trimmed
|
||||||
|
return trimmed.slice(0, DEFAULT_TITLE_MAX_LENGTH).trim() + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database singleton
|
||||||
|
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
|
||||||
|
const resetDBPromise = () => {
|
||||||
|
dbPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClosingError = (error: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "InvalidStateError" &&
|
||||||
|
/closing/i.test(error.message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDB = async <T>(
|
||||||
|
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
} catch (error) {
|
||||||
|
if (isClosingError(error)) {
|
||||||
|
resetDBPromise()
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<TemplateDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db, oldVersion) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const templateStore = db.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "id",
|
||||||
|
})
|
||||||
|
templateStore.createIndex("by-updated", "updatedAt")
|
||||||
|
templateStore.createIndex("by-pinned", "pinned")
|
||||||
|
templateStore.createIndex("by-run-count", "runCount")
|
||||||
|
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
resetDBPromise()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dbPromise
|
||||||
|
.then((db) => {
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resetDBPromise()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IndexedDB is available
|
||||||
|
export function isIndexedDBAvailable(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
return "indexedDB" in window && window.indexedDB !== null
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD Operations
|
||||||
|
|
||||||
|
export async function getAllTemplates(): Promise<Template[]> {
|
||||||
|
if (!isIndexedDBAvailable()) return []
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
const templates = await db.getAll(STORE_NAME)
|
||||||
|
return sortTemplates(templates)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get templates:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplate(id: string): Promise<Template | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get template:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(
|
||||||
|
input: TemplateCreateInput,
|
||||||
|
): Promise<Template | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
|
||||||
|
const prompt = input.prompt.trim()
|
||||||
|
if (!prompt) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const template: Template = {
|
||||||
|
id: nanoid(),
|
||||||
|
title: input.title?.trim() || generateDefaultTitle(prompt),
|
||||||
|
prompt,
|
||||||
|
description: input.description?.trim() || undefined,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: input.pinned ?? false,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.put(STORE_NAME, template)
|
||||||
|
})
|
||||||
|
return template
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create template:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTemplate(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<Template, "id" | "createdAt">>,
|
||||||
|
): Promise<Template | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
const existing = await db.get(STORE_NAME, id)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
const updated: Template = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id: existing.id,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
await db.put(STORE_NAME, updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update template:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string): Promise<boolean> {
|
||||||
|
if (!isIndexedDBAvailable()) return false
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.delete(STORE_NAME, id)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete template:", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateTemplate(
|
||||||
|
id: string,
|
||||||
|
copySuffix = "(copy)",
|
||||||
|
): Promise<Template | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
try {
|
||||||
|
return await withDB(async (db) => {
|
||||||
|
const existing = await db.get(STORE_NAME, id)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const duplicate: Template = {
|
||||||
|
...existing,
|
||||||
|
id: nanoid(),
|
||||||
|
title: `${existing.title} ${copySuffix}`,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
}
|
||||||
|
await db.put(STORE_NAME, duplicate)
|
||||||
|
return duplicate
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to duplicate template:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage tracking
|
||||||
|
|
||||||
|
export async function incrementClickCount(id: string): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
const template = await db.get(STORE_NAME, id)
|
||||||
|
if (!template) return
|
||||||
|
template.clickCount += 1
|
||||||
|
template.updatedAt = Date.now()
|
||||||
|
await db.put(STORE_NAME, template)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to increment click count:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementRunCount(id: string): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
const template = await db.get(STORE_NAME, id)
|
||||||
|
if (!template) return
|
||||||
|
const now = Date.now()
|
||||||
|
template.runCount += 1
|
||||||
|
template.lastUsedAt = now
|
||||||
|
template.updatedAt = now
|
||||||
|
await db.put(STORE_NAME, template)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to increment run count:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
export function searchTemplates(
|
||||||
|
templates: Template[],
|
||||||
|
query: string,
|
||||||
|
): Template[] {
|
||||||
|
if (!query.trim()) return templates
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
return templates.filter((t) => {
|
||||||
|
const titleMatch = t.title.toLowerCase().includes(lowerQuery)
|
||||||
|
const descMatch =
|
||||||
|
t.description?.toLowerCase().includes(lowerQuery) ?? false
|
||||||
|
return titleMatch || descMatch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
|
||||||
|
export function sortTemplates(templates: Template[]): Template[] {
|
||||||
|
return [...templates].sort((a, b) => {
|
||||||
|
// pinned desc
|
||||||
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||||
|
// runCount desc
|
||||||
|
if (a.runCount !== b.runCount) return b.runCount - a.runCount
|
||||||
|
// lastUsedAt desc
|
||||||
|
if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt
|
||||||
|
// updatedAt desc
|
||||||
|
return b.updatedAt - a.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import / Export
|
||||||
|
|
||||||
|
export const TEMPLATE_EXPORT_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
export interface TemplateExportData {
|
||||||
|
schemaVersion: number
|
||||||
|
exportedAt: number
|
||||||
|
templates: Template[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTemplates(templates: Template[]): TemplateExportData {
|
||||||
|
return {
|
||||||
|
schemaVersion: TEMPLATE_EXPORT_SCHEMA_VERSION,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
templates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImportData(data: unknown): {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return { valid: false, error: "Invalid data: expected an object" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
|
||||||
|
if (typeof obj.schemaVersion !== "number") {
|
||||||
|
return { valid: false, error: "Missing or invalid schemaVersion" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.templates)) {
|
||||||
|
return { valid: false, error: "Missing or invalid templates array" }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < obj.templates.length; i++) {
|
||||||
|
const t = obj.templates[i]
|
||||||
|
if (!t || typeof t !== "object") {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Template at index ${i} is not an object`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const template = t as Record<string, unknown>
|
||||||
|
if (typeof template.prompt !== "string" || !template.prompt.trim()) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Template at index ${i} has missing or empty prompt`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof template.title !== "string" || !template.title.trim()) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Template at index ${i} has missing or empty title`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importTemplates(
|
||||||
|
templates: Template[],
|
||||||
|
existingTemplates: Template[],
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
const existingKeys = new Set(
|
||||||
|
existingTemplates.map((t) => `${t.title}|||${t.prompt}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const t of templates) {
|
||||||
|
const key = `${t.title}|||${t.prompt}`
|
||||||
|
if (existingKeys.has(key)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const newTemplate: Template = {
|
||||||
|
id: nanoid(),
|
||||||
|
title:
|
||||||
|
String(t.title || "").trim() ||
|
||||||
|
generateDefaultTitle(String(t.prompt || "")),
|
||||||
|
prompt: String(t.prompt || "").trim(),
|
||||||
|
description: t.description ? String(t.description) : undefined,
|
||||||
|
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
|
||||||
|
updatedAt: now,
|
||||||
|
clickCount: typeof t.clickCount === "number" ? t.clickCount : 0,
|
||||||
|
runCount: typeof t.runCount === "number" ? t.runCount : 0,
|
||||||
|
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
|
||||||
|
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await withDB(async (db) => {
|
||||||
|
await db.put(STORE_NAME, newTemplate)
|
||||||
|
})
|
||||||
|
existingKeys.add(key)
|
||||||
|
imported++
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import template:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, skipped }
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Token counting utilities using js-tiktoken
|
|
||||||
*
|
|
||||||
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
|
|
||||||
* This is a pure JavaScript implementation, no WASM required.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { encodingForModel } from "js-tiktoken"
|
|
||||||
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
|
|
||||||
|
|
||||||
const encoder = encodingForModel("gpt-4o")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of tokens in a text string
|
|
||||||
* @param text - The text to count tokens for
|
|
||||||
* @returns The number of tokens
|
|
||||||
*/
|
|
||||||
export function countTextTokens(text: string): number {
|
|
||||||
return encoder.encode(text).length
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get token counts for the system prompts
|
|
||||||
* Useful for debugging and optimizing prompt sizes
|
|
||||||
* @returns Object with token counts for default and extended prompts
|
|
||||||
*/
|
|
||||||
export function getSystemPromptTokenCounts(): {
|
|
||||||
default: number
|
|
||||||
extended: number
|
|
||||||
additions: number
|
|
||||||
} {
|
|
||||||
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
|
|
||||||
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
|
|
||||||
return {
|
|
||||||
default: defaultTokens,
|
|
||||||
extended: extendedTokens,
|
|
||||||
additions: extendedTokens - defaultTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,10 @@ export type ProviderName =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
|
| "vertexai"
|
||||||
| "azure"
|
| "azure"
|
||||||
| "bedrock"
|
| "bedrock"
|
||||||
|
| "ollama"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
@@ -13,6 +15,13 @@ export type ProviderName =
|
|||||||
| "gateway"
|
| "gateway"
|
||||||
| "edgeone"
|
| "edgeone"
|
||||||
| "doubao"
|
| "doubao"
|
||||||
|
| "modelscope"
|
||||||
|
| "glm"
|
||||||
|
| "qwen"
|
||||||
|
| "qiniu"
|
||||||
|
| "kimi"
|
||||||
|
| "minimax"
|
||||||
|
| "novita"
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -34,6 +43,9 @@ export interface ProviderConfig {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string // Optional, for temporary credentials
|
awsSessionToken?: string // Optional, for temporary credentials
|
||||||
|
// Vertex AI specific fields
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
|
|
||||||
models: ModelConfig[]
|
models: ModelConfig[]
|
||||||
validated?: boolean // Has API key been validated
|
validated?: boolean // Has API key been validated
|
||||||
}
|
}
|
||||||
@@ -48,7 +60,7 @@ export interface MultiModelConfig {
|
|||||||
|
|
||||||
// Flattened model for dropdown display
|
// Flattened model for dropdown display
|
||||||
export interface FlattenedModel {
|
export interface FlattenedModel {
|
||||||
id: string // Model config UUID
|
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
|
||||||
modelId: string // Actual model ID
|
modelId: string // Actual model ID
|
||||||
provider: ProviderName
|
provider: ProviderName
|
||||||
providerLabel: string // Provider display name
|
providerLabel: string // Provider display name
|
||||||
@@ -59,7 +71,38 @@ export interface FlattenedModel {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string
|
awsSessionToken?: string
|
||||||
|
// Vertex AI specific fields
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
|
|
||||||
validated?: boolean // Has this model been validated
|
validated?: boolean // Has this model been validated
|
||||||
|
// Source of this model config: user-defined (client) or server-defined
|
||||||
|
source?: "user" | "server"
|
||||||
|
// Whether this model is the server default (matches AI_MODEL env var)
|
||||||
|
isDefault?: boolean
|
||||||
|
// Custom env var name(s) for server models
|
||||||
|
// Can be a single string or array of strings for load balancing
|
||||||
|
apiKeyEnv?: string | string[]
|
||||||
|
baseUrlEnv?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map provider names to models.dev logo names
|
||||||
|
export 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
|
||||||
|
gateway: "vercel",
|
||||||
|
edgeone: "tencent-cloud",
|
||||||
|
vertexai: "google",
|
||||||
|
doubao: "bytedance",
|
||||||
|
modelscope: "modelscope",
|
||||||
|
minimax: "minimax",
|
||||||
|
novita: "novita",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -67,34 +110,85 @@ export const PROVIDER_INFO: Record<
|
|||||||
ProviderName,
|
ProviderName,
|
||||||
{ label: string; defaultBaseUrl?: string }
|
{ label: string; defaultBaseUrl?: string }
|
||||||
> = {
|
> = {
|
||||||
openai: { label: "OpenAI" },
|
openai: {
|
||||||
|
label: "OpenAI",
|
||||||
|
defaultBaseUrl: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
anthropic: {
|
anthropic: {
|
||||||
label: "Anthropic",
|
label: "Anthropic",
|
||||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||||
},
|
},
|
||||||
google: { label: "Google" },
|
google: {
|
||||||
azure: { label: "Azure OpenAI" },
|
label: "Google",
|
||||||
|
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
},
|
||||||
|
vertexai: { label: "Google Vertex AI" },
|
||||||
|
azure: {
|
||||||
|
label: "Azure OpenAI",
|
||||||
|
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
|
||||||
|
},
|
||||||
bedrock: { label: "Amazon Bedrock" },
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
openrouter: { label: "OpenRouter" },
|
ollama: {
|
||||||
deepseek: { label: "DeepSeek" },
|
label: "Ollama",
|
||||||
|
defaultBaseUrl: "https://ollama.com/api",
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
label: "OpenRouter",
|
||||||
|
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
label: "DeepSeek",
|
||||||
|
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||||
|
},
|
||||||
siliconflow: {
|
siliconflow: {
|
||||||
label: "SiliconFlow",
|
label: "SiliconFlow",
|
||||||
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
||||||
},
|
},
|
||||||
sglang: {
|
sglang: {
|
||||||
label: "SGLang",
|
label: "SGLang",
|
||||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||||
},
|
},
|
||||||
gateway: { label: "AI Gateway" },
|
gateway: {
|
||||||
|
label: "AI Gateway",
|
||||||
|
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
||||||
|
},
|
||||||
edgeone: { label: "EdgeOne Pages" },
|
edgeone: { label: "EdgeOne Pages" },
|
||||||
doubao: {
|
doubao: {
|
||||||
label: "Doubao (ByteDance)",
|
label: "Doubao (ByteDance)",
|
||||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
},
|
},
|
||||||
|
modelscope: {
|
||||||
|
label: "ModelScope",
|
||||||
|
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||||
|
},
|
||||||
|
glm: {
|
||||||
|
label: "GLM (Zhipu)",
|
||||||
|
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
label: "Qwen (Alibaba)",
|
||||||
|
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
},
|
||||||
|
qiniu: {
|
||||||
|
label: "Qiniu",
|
||||||
|
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
label: "Kimi (Moonshot)",
|
||||||
|
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
||||||
|
},
|
||||||
|
minimax: {
|
||||||
|
label: "MiniMax",
|
||||||
|
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||||
|
},
|
||||||
|
novita: {
|
||||||
|
label: "Novita AI",
|
||||||
|
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||||
openai: [
|
openai: [
|
||||||
"gpt-5.2-pro",
|
"gpt-5.2-pro",
|
||||||
"gpt-5.2-chat-latest",
|
"gpt-5.2-chat-latest",
|
||||||
@@ -147,6 +241,17 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
|||||||
// Legacy
|
// Legacy
|
||||||
"gemini-pro",
|
"gemini-pro",
|
||||||
],
|
],
|
||||||
|
vertexai: [
|
||||||
|
// Gemini 2.5 series
|
||||||
|
"gemini-2.5-pro",
|
||||||
|
"gemini-2.5-flash",
|
||||||
|
// Gemini 2.0 series
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"gemini-2.0-flash-exp",
|
||||||
|
// Gemini 1.5 series
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
],
|
||||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||||
bedrock: [
|
bedrock: [
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
@@ -209,6 +314,7 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
|||||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||||
"Qwen/Qwen2.5-7B-Instruct",
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
"Qwen/Qwen2-VL-72B-Instruct",
|
"Qwen/Qwen2-VL-72B-Instruct",
|
||||||
|
"qwen3.5-plus",
|
||||||
],
|
],
|
||||||
sglang: [
|
sglang: [
|
||||||
// SGLang is OpenAI-compatible, models depend on deployment
|
// SGLang is OpenAI-compatible, models depend on deployment
|
||||||
@@ -231,6 +337,31 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
|||||||
"doubao-pro-32k-241215",
|
"doubao-pro-32k-241215",
|
||||||
"doubao-pro-256k-241215",
|
"doubao-pro-256k-241215",
|
||||||
],
|
],
|
||||||
|
modelscope: [
|
||||||
|
// Qwen
|
||||||
|
"Qwen/Qwen2.5-72B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-32B-Instruct",
|
||||||
|
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
|
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||||
|
"Qwen/Qwen3-32B",
|
||||||
|
"qwen3.5-plus",
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
|
"deepseek-ai/DeepSeek-V3.2",
|
||||||
|
],
|
||||||
|
minimax: [
|
||||||
|
// MiniMax models (Anthropic-compatible API)
|
||||||
|
"MiniMax-M2.7",
|
||||||
|
"MiniMax-M2.7-highspeed",
|
||||||
|
"MiniMax-M2.5",
|
||||||
|
"MiniMax-M2.5-highspeed",
|
||||||
|
],
|
||||||
|
novita: [
|
||||||
|
// Novita AI models (OpenAI-compatible API)
|
||||||
|
"moonshotai/kimi-k2.5",
|
||||||
|
"zai-org/glm-5",
|
||||||
|
"minimax/minimax-m2.5",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
@@ -267,7 +398,7 @@ export function createModelConfig(modelId: string): ModelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all models as flattened list for dropdown
|
// Get all models as flattened list for dropdown (user-defined only)
|
||||||
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
||||||
const models: FlattenedModel[] = []
|
const models: FlattenedModel[] = []
|
||||||
|
|
||||||
@@ -289,7 +420,12 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
|||||||
awsSecretAccessKey: provider.awsSecretAccessKey,
|
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||||
awsRegion: provider.awsRegion,
|
awsRegion: provider.awsRegion,
|
||||||
awsSessionToken: provider.awsSessionToken,
|
awsSessionToken: provider.awsSessionToken,
|
||||||
|
// Vertex AI fields
|
||||||
|
vertexApiKey: provider.vertexApiKey,
|
||||||
|
|
||||||
validated: model.validated,
|
validated: model.validated,
|
||||||
|
source: "user",
|
||||||
|
isDefault: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
lib/url-utils.ts
Normal file
50
lib/url-utils.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
|
||||||
|
export interface UrlData {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
charCount: number
|
||||||
|
isExtracting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const UrlResponseSchema = z.object({
|
||||||
|
title: z.string().default("Untitled"),
|
||||||
|
content: z.string(),
|
||||||
|
charCount: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||||
|
const response = await fetch(getApiEndpoint("/api/parse-url"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to parse JSON once
|
||||||
|
const raw = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unexpected non-JSON response" }))
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
typeof raw === "object" && raw && "error" in raw
|
||||||
|
? String((raw as any).error)
|
||||||
|
: "Failed to extract URL content"
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UrlResponseSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error("Malformed response from URL extraction API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: parsed.data.title,
|
||||||
|
content: parsed.data.content,
|
||||||
|
charCount: parsed.data.charCount,
|
||||||
|
isExtracting: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
lib/utils.ts
28
lib/utils.ts
@@ -1,11 +1,33 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import * as pako from "pako"
|
import * as pako from "pako"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import type { DiagramOperation } from "@/components/chat/types"
|
||||||
|
|
||||||
|
export type { DiagramOperation }
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Diagram Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length for a "real" diagram XML (not just empty template).
|
||||||
|
* Empty mxfile templates are ~147-300 chars; real diagrams are larger.
|
||||||
|
*/
|
||||||
|
export const MIN_REAL_DIAGRAM_LENGTH = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if diagram XML represents a real diagram (not just empty template).
|
||||||
|
* @param xml - The diagram XML string to check
|
||||||
|
* @returns true if the XML is a real diagram with content
|
||||||
|
*/
|
||||||
|
export function isRealDiagram(xml: string | undefined | null): boolean {
|
||||||
|
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// XML Validation/Fix Constants
|
// XML Validation/Fix Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -454,12 +476,6 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
// ID-based Diagram Operations
|
// ID-based Diagram Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface DiagramOperation {
|
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OperationError {
|
export interface OperationError {
|
||||||
type: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cellId: string
|
cellId: string
|
||||||
|
|||||||
22
lib/validation-prompts.ts
Normal file
22
lib/validation-prompts.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* VLM system prompt for diagram validation.
|
||||||
|
* Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.
|
||||||
|
|
||||||
|
Evaluate the diagram for the following issues:
|
||||||
|
|
||||||
|
1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable
|
||||||
|
2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target
|
||||||
|
3. **Text readability** (warning): Labels cut off, overlapping, or too small to read
|
||||||
|
4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements
|
||||||
|
5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Set "valid" to true ONLY if there are no critical issues
|
||||||
|
- Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box")
|
||||||
|
- Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left")
|
||||||
|
- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical
|
||||||
|
- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors
|
||||||
|
- If the diagram looks generally acceptable, set valid to true even with minor warnings`
|
||||||
38
lib/validation-schema.ts
Normal file
38
lib/validation-schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation schema for VLM-based diagram validation.
|
||||||
|
* This file can be safely imported on both client and server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
// Schema for structured validation output
|
||||||
|
export const ValidationResultSchema = z.object({
|
||||||
|
valid: z.boolean().describe("True if there are no critical issues"),
|
||||||
|
issues: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z
|
||||||
|
.enum([
|
||||||
|
"overlap",
|
||||||
|
"edge_routing",
|
||||||
|
"text",
|
||||||
|
"layout",
|
||||||
|
"rendering",
|
||||||
|
])
|
||||||
|
.describe("Type of visual issue"),
|
||||||
|
severity: z
|
||||||
|
.enum(["critical", "warning"])
|
||||||
|
.describe("Severity level"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.describe("Clear description of the issue"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("List of visual issues found"),
|
||||||
|
suggestions: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("Actionable suggestions to fix issues"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
||||||
|
export type ValidationIssue = ValidationResult["issues"][number]
|
||||||
16505
package-lock.json
generated
16505
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user