mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 11:17:04 +08:00
Compare commits
202
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab33dae69d | ||
|
|
0b63e28e5a | ||
|
|
a45e5b6796 | ||
|
|
027cd88c90 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 | ||
|
|
0f9699843f | ||
|
|
988034cc3e | ||
|
|
8bc9871829 | ||
|
|
449e4c4e26 | ||
|
|
54ff8d982c | ||
|
|
410993a3bf | ||
|
|
77e7766f9a | ||
|
|
a9ffd6a1de | ||
|
|
277ad83552 | ||
|
|
7b6eb39fa5 | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
524b77a948 | ||
|
|
9bf0c7f23f | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
43cc4cb657 | ||
|
|
fd84aa70db | ||
|
|
8b0beb68c0 | ||
|
|
7a6a933cf6 | ||
|
|
05bda50b2a | ||
|
|
e7453e86a6 | ||
|
|
be4bc916fd | ||
|
|
c4ba5d4ea8 | ||
|
|
ff07975574 | ||
|
|
ffac74b0ff | ||
|
|
69bd13bc93 | ||
|
|
dd9d79d2d6 | ||
|
|
f3c3614b53 | ||
|
|
6586c53093 | ||
|
|
c083782802 | ||
|
|
a5d1554c3f | ||
|
|
e171fbcdd8 | ||
|
|
89d3968733 | ||
|
|
ac3570c1b0 | ||
|
|
41dc0b2b42 | ||
|
|
3041dafe2f | ||
|
|
7fbc857d3a | ||
|
|
d752524851 | ||
|
|
ecce0a96c7 | ||
|
|
cd33e131ef | ||
|
|
4624ad40a1 | ||
|
|
fa1548d3eb | ||
|
|
eb834a341f | ||
|
|
b84b156c8c | ||
|
|
c6eab07622 | ||
|
|
c382d3c0f4 | ||
|
|
e065d4b727 | ||
|
|
b2b9614404 | ||
|
|
1b96325fea | ||
|
|
19dd4e287f | ||
|
|
699767f392 | ||
|
|
feeb9ec0c5 | ||
|
|
f1a3044224 | ||
|
|
71a1d02e28 | ||
|
|
2d17963b2a | ||
|
|
3721b16d18 | ||
|
|
cbde35fb54 | ||
|
|
f098e557fc | ||
|
|
a245dcb150 | ||
|
|
f2f5ac907d | ||
|
|
0a79536002 | ||
|
|
f2c8fea58d | ||
|
|
31fffcc52d | ||
|
|
0a03ac9f45 | ||
|
|
6dad6ab147 | ||
|
|
8781520ebb | ||
|
|
67196225f0 | ||
|
|
09a5774cba | ||
|
|
fdd4be6463 | ||
|
|
f8a0ebd149 | ||
|
|
c9dd54dad7 | ||
|
|
1258f98478 | ||
|
|
f6f693ee8d | ||
|
|
f19dc919e1 | ||
|
|
9398117368 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 | ||
|
|
0baa424bc4 | ||
|
|
afddba364b | ||
|
|
b386dc45e6 | ||
|
|
7b5a3075cf | ||
|
|
89a0e6d475 | ||
|
|
56df2678bf | ||
|
|
c9e0841583 | ||
|
|
552a2b2ab4 | ||
|
|
78ce5611d3 | ||
|
|
629ba16e7c | ||
|
|
91ca2d4f21 | ||
|
|
9655811425 | ||
|
|
31d0e6d3dc | ||
|
|
92e908aed8 | ||
|
|
dcb6505b49 | ||
|
|
4ace31d412 | ||
|
|
9caf2f793e | ||
|
|
21567744ad | ||
|
|
44699940ce | ||
|
|
5007c7bbe4 | ||
|
|
1ad6575e04 | ||
|
|
85be3a2561 | ||
|
|
b23b9179a0 | ||
|
|
b128c57e94 | ||
|
|
4691a71190 | ||
|
|
04290a53d0 | ||
|
|
3731301162 | ||
|
|
3b50c08258 | ||
|
|
e5f647171c | ||
|
|
476ef3c7d1 | ||
|
|
6bd26c8bbd | ||
|
|
d2e51f159f | ||
|
|
9677737745 | ||
|
|
d5774b336c | ||
|
|
6b70fdbeda | ||
|
|
af913f7223 | ||
|
|
72d438e53a | ||
|
|
0d79487b6c | ||
|
|
75e578b5fc | ||
|
|
0009900b1b | ||
|
|
6a20f03805 | ||
|
|
8f538193dd | ||
|
|
cf9638b231 | ||
|
|
fbce1baf16 | ||
|
|
c3d3afc202 | ||
|
|
651238529a |
@@ -47,6 +47,10 @@ To run tests with UI mode:
|
|||||||
npx playwright test --ui
|
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
|
||||||
@@ -57,6 +61,21 @@ npx playwright test --ui
|
|||||||
|
|
||||||
CI will run the full test suite on your PR.
|
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
|
||||||
|
|
||||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
"groupName": "Core framework packages",
|
"groupName": "Core framework packages",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["@biomejs/biome"],
|
||||||
|
"groupName": "Biome",
|
||||||
|
"automerge": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Run Biome format
|
- name: Run Biome format
|
||||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
# Pin to the version in package.json so CI matches local/pre-commit
|
||||||
|
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||||
|
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -40,5 +40,3 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Security audit
|
|
||||||
run: npm audit --audit-level=high --omit=dev
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -89,4 +91,3 @@ jobs:
|
|||||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -57,6 +66,16 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ jobs:
|
|||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||||
project-slug: 'next-ai-draw-io'
|
project-slug: 'next-ai-draw-io'
|
||||||
signing-policy-slug: 'test-signing'
|
signing-policy-slug: 'release-signing'
|
||||||
artifact-configuration-slug: 'windows-exe'
|
artifact-configuration-slug: 'windows-exe'
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Publish MCP Server
|
||||||
|
|
||||||
|
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||||
|
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||||
|
# skips silently if the package.json version is already on npm — so a
|
||||||
|
# release is just "bump the version in a PR and merge".
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/mcp-server/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # OIDC token for npm trusted publishing
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-mcp
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/mcp-server
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
# Trusted publishing requires npm >= 11.5.1
|
||||||
|
- name: Update npm
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- name: Check if version is already published
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
LOCAL=$(node -p "require('./package.json').version")
|
||||||
|
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||||
|
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||||
|
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Version ${LOCAL} not on npm - publishing"
|
||||||
|
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build and check package contents
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm run build && npm run check-package
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
run: npm publish
|
||||||
@@ -28,6 +28,20 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
|
# The MCP server package ships its own vitest because its DOM polyfill
|
||||||
|
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||||
|
# for the Next.js app. Install + run its tests separately so CI catches
|
||||||
|
# multi-page mxfile regressions.
|
||||||
|
- name: Install MCP server dependencies
|
||||||
|
run: npm --prefix packages/mcp-server ci
|
||||||
|
|
||||||
|
- name: Run MCP server unit tests
|
||||||
|
run: npm --prefix packages/mcp-server test
|
||||||
|
|
||||||
|
# Tests run from src/, so check the built npm package separately
|
||||||
|
- name: Build MCP server and check package contents
|
||||||
|
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+11
-1
@@ -56,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
|
||||||
@@ -68,4 +70,12 @@ CLAUDE.md
|
|||||||
|
|
||||||
# edgeone
|
# edgeone
|
||||||
.edgeone
|
.edgeone
|
||||||
opencode.json
|
opencode.json
|
||||||
|
ai-models.json
|
||||||
|
|
||||||
|
# local backups
|
||||||
|
*.bak
|
||||||
|
.gstack/
|
||||||
|
|
||||||
|
# admin panel settings (contains secrets)
|
||||||
|
data/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +35,11 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
|||||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=${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
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ COPY --from=builder /app/public ./public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Writable dir for admin panel settings (data/settings.json)
|
||||||
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -19,7 +19,18 @@ 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!
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||||
|
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||||
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
@@ -31,7 +42,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)
|
||||||
@@ -43,6 +54,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
- [Deploy on Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
|
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
||||||
|
- [Admin Panel](#admin-panel)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -63,24 +76,24 @@ Here are some example prompts and their generated diagrams:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCP architecture diagram</strong><br />
|
<strong>RAG Technique Diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS architecture diagram</strong><br />
|
<strong>Authentication using React and AWS</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure architecture diagram</strong><br />
|
<strong>Open Innovation</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch prompt</strong><br />
|
<strong>Cat sketch</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||||
</td>
|
</td>
|
||||||
@@ -99,9 +112,7 @@ Here are some example prompts and their generated diagrams:
|
|||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||||
|
|
||||||
## MCP Server (Preview)
|
## MCP Server
|
||||||
|
|
||||||
> **Preview Feature**: This feature is experimental and may not 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).
|
||||||
|
|
||||||
@@ -127,6 +138,13 @@ Then ask Claude to create diagrams:
|
|||||||
|
|
||||||
The diagram appears in your browser in real-time!
|
The diagram appears in your browser in real-time!
|
||||||
|
|
||||||
|
The MCP server includes most of the web app's drawing features:
|
||||||
|
|
||||||
|
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||||
|
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
||||||
|
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
||||||
|
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
||||||
|
|
||||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -202,25 +220,38 @@ 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
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||||
|
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||||
|
|
||||||
|
### Server-Side Multi-Model Configuration
|
||||||
|
|
||||||
|
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
|
||||||
|
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
||||||
|
|
||||||
|
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
Note that 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.
|
||||||
@@ -239,7 +270,9 @@ 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!
|
||||||
|
|
||||||
|
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||||
|
|
||||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -78,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"
|
||||||
@@ -87,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">
|
||||||
@@ -97,6 +97,23 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Poster */}
|
||||||
|
<div className="text-center mb-5">
|
||||||
|
<a
|
||||||
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/volcengine-invite.png"
|
||||||
|
alt="火山引擎方舟 Coding Plan"
|
||||||
|
width={300}
|
||||||
|
height={400}
|
||||||
|
className="mx-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* 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">
|
||||||
@@ -158,92 +175,106 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
动画Transformer连接器
|
ResNet50模型架构动画
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>提示词:</strong> 给我一个带有
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>动画连接器</strong>的Transformer架构图。
|
<strong>animated</strong> architecture diagram
|
||||||
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="带动画连接器的Transformer架构"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50模型架构图"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCP架构图
|
RAG技术图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCP图标</strong>
|
architecture diagram for{" "}
|
||||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP架构图"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWS架构图
|
React和AWS认证流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWS图标</strong>
|
authentication process using React with{" "}
|
||||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS架构图"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="认证架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azure架构图
|
敏捷Scrum流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azure图标</strong>
|
scrum workflow diagram for software
|
||||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure架构图"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="敏捷Scrum流程图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
猫咪素描
|
开放式创新
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
给我画一只可爱的猫。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫咪绘图"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="开放式创新图"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +308,7 @@ export default function AboutCN() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -292,6 +323,7 @@ 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>
|
||||||
@@ -311,7 +343,7 @@ export default function AboutCN() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
特别感谢{" "}
|
特别感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -86,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"
|
||||||
@@ -95,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">
|
||||||
@@ -168,93 +168,106 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
アニメーションTransformerコネクタ
|
ResNet50モデルアーキテクチャアニメーション
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>アニメーションコネクタ</strong>
|
<strong>animated</strong> architecture diagram
|
||||||
付きのTransformerアーキテクチャ図を作成してください。
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50モデルアーキテクチャ図"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCPアーキテクチャ図
|
RAG技術ダイアグラム
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCPアイコン</strong>
|
architecture diagram for{" "}
|
||||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCPアーキテクチャ図"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAGアーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWSアーキテクチャ図
|
ReactとAWSによる認証
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWSアイコン</strong>
|
authentication process using React with{" "}
|
||||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWSアーキテクチャ図"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="認証アーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azureアーキテクチャ図
|
アジャイルスクラムプロセス
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azureアイコン</strong>
|
scrum workflow diagram for software
|
||||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azureアーキテクチャ図"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="アジャイルスクラム図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
猫のスケッチ
|
オープンイノベーション
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
かわいい猫を描いてください。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫の絵"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="オープンイノベーション図"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +305,7 @@ export default function AboutJA() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -307,6 +320,7 @@ 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>
|
||||||
@@ -326,7 +340,7 @@ export default function AboutJA() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
+74
-63
@@ -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",
|
||||||
@@ -87,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"
|
||||||
@@ -96,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{" "}
|
||||||
@@ -182,96 +182,106 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated Transformer Connectors
|
Animated ResNet50 Model Architecture
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>animated connector</strong> diagram of
|
<strong>animated</strong> architecture diagram
|
||||||
transformer's architecture.
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="Transformer Architecture with Animated Connectors"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="Architecture diagram for ResNet50 model"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCP Architecture Diagram
|
RAG Technique Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a GCP
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
architecture diagram with{" "}
|
architecture diagram for{" "}
|
||||||
<strong>GCP icons</strong>. Users connect to
|
<strong>chat application</strong>. Use
|
||||||
a frontend hosted on an instance.
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP Architecture Diagram"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWS Architecture Diagram
|
Authentication using React and AWS
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an AWS
|
<strong>Prompt:</strong> Generate
|
||||||
architecture diagram with{" "}
|
authentication process using React with{" "}
|
||||||
<strong>AWS icons</strong>. Users connect to
|
<strong>AWS</strong>. Use Serverless
|
||||||
a frontend hosted on an instance.
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS Architecture Diagram"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="Authentication Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azure Architecture Diagram
|
Agile Scrum Process
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an Azure
|
<strong>Prompt:</strong> Generate agile
|
||||||
architecture diagram with{" "}
|
scrum workflow diagram for software
|
||||||
<strong>Azure icons</strong>. Users connect
|
development team.
|
||||||
to a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure Architecture Diagram"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="Agile Scrum Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Cat Sketch
|
Open Innovation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Draw a cute cat for
|
<strong>Prompt:</strong> Create
|
||||||
me.
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="Cat Drawing"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="Open Innovation Diagram"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +321,7 @@ export default function About() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -326,6 +336,7 @@ 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>
|
||||||
@@ -347,7 +358,7 @@ export default function About() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
Special thanks to{" "}
|
Special thanks to{" "}
|
||||||
<a
|
<a
|
||||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||||
|
|
||||||
|
// ── Shared types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SecretValue = { isSet: true; hint: string }
|
||||||
|
|
||||||
|
export function isSecretValue(v: unknown): v is SecretValue {
|
||||||
|
return typeof v === "object" && v !== null && "isSet" in v
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingState {
|
||||||
|
key: string
|
||||||
|
source: "file" | "env" | "default"
|
||||||
|
value: string | SecretValue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsMap = Record<string, SettingState>
|
||||||
|
|
||||||
|
// Editable text of a saved setting; secrets have none (write-only)
|
||||||
|
export function savedTextOf(state: SettingState | undefined): string {
|
||||||
|
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin provider in client state. Secret fields hold either a masked
|
||||||
|
// marker (unchanged) or a plaintext string (new value).
|
||||||
|
export interface AdminProvider {
|
||||||
|
id: string
|
||||||
|
provider: ProviderName
|
||||||
|
name?: string
|
||||||
|
apiKey?: string | SecretValue
|
||||||
|
baseUrl?: string
|
||||||
|
awsAccessKeyId?: string | SecretValue
|
||||||
|
awsSecretAccessKey?: string | SecretValue
|
||||||
|
awsRegion?: string
|
||||||
|
vertexApiKey?: string | SecretValue
|
||||||
|
models: string[]
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||||
|
export interface EnvProvider {
|
||||||
|
name: string
|
||||||
|
provider: ProviderName
|
||||||
|
models: string[]
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
||||||
|
const res = await fetch(getApiEndpoint(path), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init?.headers,
|
||||||
|
"x-admin-password": pw,
|
||||||
|
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || `Request failed (${res.status})`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||||
|
import { ProviderLogo } from "@/components/provider-logo"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import {
|
||||||
|
FIXED_CRED_PROVIDERS,
|
||||||
|
generateId,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderName,
|
||||||
|
SUGGESTED_MODELS,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type AdminProvider,
|
||||||
|
adminFetch,
|
||||||
|
type EnvProvider,
|
||||||
|
} from "./admin-shared"
|
||||||
|
import { SecretInput } from "./setting-field"
|
||||||
|
|
||||||
|
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
||||||
|
|
||||||
|
function ProviderDetail({
|
||||||
|
provider,
|
||||||
|
disabled,
|
||||||
|
password,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
provider: AdminProvider
|
||||||
|
disabled: boolean
|
||||||
|
password: string
|
||||||
|
onUpdate: (patch: Partial<AdminProvider>) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [modelInput, setModelInput] = useState("")
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [testing, setTesting] = useState<string | null>(null)
|
||||||
|
const [testResults, setTestResults] = useState<
|
||||||
|
Record<string, { ok: boolean; message: string }>
|
||||||
|
>({})
|
||||||
|
|
||||||
|
const info = PROVIDER_INFO[provider.provider]
|
||||||
|
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||||
|
(m) => !provider.models.includes(m),
|
||||||
|
)
|
||||||
|
|
||||||
|
const addModel = (modelId: string) => {
|
||||||
|
const trimmed = modelId.trim()
|
||||||
|
if (!trimmed || provider.models.includes(trimmed)) return
|
||||||
|
onUpdate({ models: [...provider.models, trimmed] })
|
||||||
|
setModelInput("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const testModel = async (modelId: string) => {
|
||||||
|
setTesting(modelId)
|
||||||
|
try {
|
||||||
|
const data = await adminFetch("/api/admin/test-model", password, {
|
||||||
|
method: "POST",
|
||||||
|
// EdgeOne's function also checks the access code
|
||||||
|
headers: {
|
||||||
|
"x-access-code":
|
||||||
|
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ provider, modelId }),
|
||||||
|
})
|
||||||
|
setTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[modelId]: data.valid
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
message: formatMessage(dict.admin.testOk, {
|
||||||
|
ms: data.responseTime,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
message: data.error || dict.admin.testFailed,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[modelId]: {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: dict.admin.testFailed,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
setTesting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<ProviderLogo
|
||||||
|
provider={provider.provider}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold">{info.label}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{provider.models.length === 0
|
||||||
|
? dict.admin.noModelsConfigured
|
||||||
|
: formatMessage(
|
||||||
|
provider.models.length === 1
|
||||||
|
? dict.admin.modelCount
|
||||||
|
: dict.admin.modelCountPlural,
|
||||||
|
{ count: provider.models.length },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
provider.isDefault &&
|
||||||
|
"fill-amber-400 text-amber-400",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.default}
|
||||||
|
<Switch
|
||||||
|
checked={!!provider.isDefault}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={dict.admin.setAsDefault}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdate({ isDefault: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
{dict.admin.delete}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||||
|
<ProviderCredentialsFields
|
||||||
|
provider={provider.provider}
|
||||||
|
name={provider.name}
|
||||||
|
baseUrl={provider.baseUrl}
|
||||||
|
awsRegion={provider.awsRegion}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||||
|
renderSecret={({ field, id }) => (
|
||||||
|
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||||
|
// associated; only one ProviderDetail is mounted at a time.
|
||||||
|
<SecretInput
|
||||||
|
id={id}
|
||||||
|
keepOnEmpty
|
||||||
|
value={provider[field]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => onUpdate({ [field]: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Models */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{dict.admin.models}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={modelInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={dict.admin.modelIdPlaceholder}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-8 w-48 font-mono text-xs"
|
||||||
|
onChange={(e) => setModelInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") addModel(modelInput)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={disabled || !modelInput.trim()}
|
||||||
|
aria-label={dict.admin.addModel}
|
||||||
|
onClick={() => addModel(modelInput)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => addModel(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-28 text-xs">
|
||||||
|
{dict.admin.suggested}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{suggestions.map((m) => (
|
||||||
|
<SelectItem
|
||||||
|
key={m}
|
||||||
|
value={m}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
{provider.models.length === 0 ? (
|
||||||
|
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||||
|
{dict.admin.addProviderToOfferModels}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{provider.models.map((modelId, index) => {
|
||||||
|
const result = testResults[modelId]
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={modelId}
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{modelId}
|
||||||
|
{provider.isDefault &&
|
||||||
|
index === 0 && (
|
||||||
|
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.defaultModel
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{result && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs",
|
||||||
|
result.ok
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.ok ? (
|
||||||
|
<Check
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AlertCircle
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-48 truncate">
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={
|
||||||
|
disabled || testing !== null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void testModel(modelId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{testing === modelId ? (
|
||||||
|
<Loader2
|
||||||
|
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Zap
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="ml-1">
|
||||||
|
{dict.admin.test}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={formatMessage(
|
||||||
|
dict.admin.removeModel,
|
||||||
|
{ model: modelId },
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
onUpdate({
|
||||||
|
models: provider.models.filter(
|
||||||
|
(m) => m !== modelId,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||||
|
name: provider.name || info.label,
|
||||||
|
})}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.admin.deleteProviderDesc}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.admin.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dict.admin.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelsSection({
|
||||||
|
providers,
|
||||||
|
envProviders,
|
||||||
|
envHasDefaultModel,
|
||||||
|
disabled,
|
||||||
|
password,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
providers: AdminProvider[]
|
||||||
|
envProviders: EnvProvider[]
|
||||||
|
envHasDefaultModel: boolean
|
||||||
|
disabled: boolean
|
||||||
|
password: string
|
||||||
|
onChange: (providers: AdminProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
providers[0]?.id ?? null,
|
||||||
|
)
|
||||||
|
const selected = providers.find((p) => p.id === selectedId)
|
||||||
|
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||||
|
|
||||||
|
const addProvider = (provider: ProviderName) => {
|
||||||
|
const newProvider: AdminProvider = {
|
||||||
|
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||||
|
id: generateId(),
|
||||||
|
provider,
|
||||||
|
models: [],
|
||||||
|
// Only the very first provider becomes the default, and only when
|
||||||
|
// the env config has no default that it would replace on save
|
||||||
|
isDefault:
|
||||||
|
providers.length === 0 &&
|
||||||
|
!envProviders.some((p) => p.isDefault) &&
|
||||||
|
!envHasDefaultModel,
|
||||||
|
}
|
||||||
|
onChange([...providers, newProvider])
|
||||||
|
setSelectedId(newProvider.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||||
|
onChange(
|
||||||
|
providers.map((p) => {
|
||||||
|
if (p.id !== id) {
|
||||||
|
// Only one default at a time
|
||||||
|
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||||
|
}
|
||||||
|
return { ...p, ...patch }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProvider = (id: string) => {
|
||||||
|
const next = providers.filter((p) => p.id !== id)
|
||||||
|
onChange(next)
|
||||||
|
setSelectedId(next[0]?.id ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||||
|
{/* Provider list */}
|
||||||
|
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||||
|
<div className="flex-1 space-y-1 p-2">
|
||||||
|
{providers.length === 0 && envProviders.length === 0 && (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{dict.admin.addProviderHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{envProviders.map((p) => (
|
||||||
|
<button
|
||||||
|
key={`env:${p.name}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
selectedId === `env:${p.name}` &&
|
||||||
|
"bg-muted font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderLogo provider={p.provider} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||||
|
{dict.admin.sourceEnv}
|
||||||
|
</span>
|
||||||
|
{p.isDefault && (
|
||||||
|
<Star
|
||||||
|
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||||
|
aria-label={dict.admin.defaultProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
selectedId === p.id && "bg-muted font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderLogo provider={p.provider} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{p.name || PROVIDER_INFO[p.provider].label}
|
||||||
|
</span>
|
||||||
|
{p.isDefault && (
|
||||||
|
<Star
|
||||||
|
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||||
|
aria-label={dict.admin.defaultProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t p-2">
|
||||||
|
{/* Always empty so picking the same type again still fires */}
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<Plus
|
||||||
|
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.modelConfig.addProvider}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||||
|
(p) => {
|
||||||
|
// Global-credential providers already in
|
||||||
|
// the env config can't be added here —
|
||||||
|
// panel credentials would override theirs
|
||||||
|
const envBlocked =
|
||||||
|
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||||
|
envProviders.some(
|
||||||
|
(e) => e.provider === p,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={p}
|
||||||
|
value={p}
|
||||||
|
disabled={envBlocked}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderLogo provider={p} />
|
||||||
|
{PROVIDER_INFO[p].label}
|
||||||
|
{envBlocked && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.managedViaEnv
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
<div className="min-w-0 flex-1 p-4">
|
||||||
|
{selected ? (
|
||||||
|
<ProviderDetail
|
||||||
|
key={selected.id}
|
||||||
|
provider={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
password={password}
|
||||||
|
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||||
|
onDelete={() => deleteProvider(selected.id)}
|
||||||
|
/>
|
||||||
|
) : selectedEnv ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<ProviderLogo
|
||||||
|
provider={selectedEnv.provider}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{selectedEnv.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.admin.envReadOnly}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<ul className="divide-y">
|
||||||
|
{selectedEnv.models.map((modelId, index) => (
|
||||||
|
<li
|
||||||
|
key={modelId}
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{modelId}
|
||||||
|
{selectedEnv.isDefault &&
|
||||||
|
index === 0 && (
|
||||||
|
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||||
|
{
|
||||||
|
dict.admin
|
||||||
|
.defaultModel
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{dict.admin.selectProviderHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
SETTING_GROUPS,
|
||||||
|
SETTINGS_BY_GROUP,
|
||||||
|
} from "@/lib/admin/settings-registry"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type AdminProvider,
|
||||||
|
adminFetch,
|
||||||
|
type EnvProvider,
|
||||||
|
isSecretValue,
|
||||||
|
SESSION_PASSWORD_KEY,
|
||||||
|
type SettingState,
|
||||||
|
type SettingsMap,
|
||||||
|
savedTextOf,
|
||||||
|
} from "./admin-shared"
|
||||||
|
import { ModelsSection } from "./models-section"
|
||||||
|
import { SettingField } from "./setting-field"
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||||
|
|
||||||
|
// For each toggleable group, whether any of its settings has a value (from
|
||||||
|
// the settings file or the environment)
|
||||||
|
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
|
||||||
|
const result: Record<string, boolean> = {}
|
||||||
|
for (const group of SETTING_GROUPS) {
|
||||||
|
if (!group.toggleable) continue
|
||||||
|
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||||
|
(d) => map[d.key]?.source !== "default",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const dict = useDictionary()
|
||||||
|
// Localized group title/description, keyed by group id
|
||||||
|
const groupText = (id: string) =>
|
||||||
|
(
|
||||||
|
dict.admin.groups as Record<
|
||||||
|
string,
|
||||||
|
{ title: string; description: string } | undefined
|
||||||
|
>
|
||||||
|
)[id]
|
||||||
|
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
title:
|
||||||
|
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||||
|
}))
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||||
|
const [authError, setAuthError] = useState("")
|
||||||
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
|
|
||||||
|
const [writable, setWritable] = useState(true)
|
||||||
|
|
||||||
|
// Models section state
|
||||||
|
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||||
|
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||||
|
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||||
|
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||||
|
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||||
|
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||||
|
|
||||||
|
// General settings state
|
||||||
|
const [settings, setSettings] = useState<SettingsMap>({})
|
||||||
|
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveMessage, setSaveMessage] = useState<{
|
||||||
|
ok: boolean
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
const [activeGroup, setActiveGroup] = useState("models")
|
||||||
|
|
||||||
|
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||||
|
|
||||||
|
const applySettingsResponse = useCallback(
|
||||||
|
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||||
|
setWritable(data.writable)
|
||||||
|
const map: SettingsMap = {}
|
||||||
|
for (const s of data.settings) map[s.key] = s
|
||||||
|
setSettings(map)
|
||||||
|
// A group stays on while it still has values (e.g. from env vars
|
||||||
|
// that saving can't remove); a user's explicit "on" for a group
|
||||||
|
// with no values yet is kept across saves
|
||||||
|
setEnabledGroups((prev) => {
|
||||||
|
const next = groupsWithValues(map)
|
||||||
|
for (const id of Object.keys(next)) {
|
||||||
|
next[id] = next[id] || !!prev[id]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyProvidersResponse = useCallback(
|
||||||
|
(data: {
|
||||||
|
providers: AdminProvider[]
|
||||||
|
envProviders?: EnvProvider[]
|
||||||
|
envHasDefaultModel?: boolean
|
||||||
|
}) => {
|
||||||
|
setProviders(data.providers)
|
||||||
|
setSavedProviders(JSON.stringify(data.providers))
|
||||||
|
setEnvProviders(data.envProviders ?? [])
|
||||||
|
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (pw: string) => {
|
||||||
|
setAuthLoading(true)
|
||||||
|
setAuthError("")
|
||||||
|
try {
|
||||||
|
const [settingsData, providersData] = await Promise.all([
|
||||||
|
adminFetch("/api/admin/settings", pw),
|
||||||
|
adminFetch("/api/admin/providers", pw),
|
||||||
|
])
|
||||||
|
applySettingsResponse(settingsData)
|
||||||
|
applyProvidersResponse(providersData)
|
||||||
|
setAuthedPassword(pw)
|
||||||
|
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||||
|
} catch (err) {
|
||||||
|
setAuthError(
|
||||||
|
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applySettingsResponse, applyProvidersResponse, dict],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||||
|
if (stored) void login(stored)
|
||||||
|
}, [login])
|
||||||
|
|
||||||
|
// Warn before leaving with unsaved changes
|
||||||
|
const hasDirty = dirtyCount > 0
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDirty) return
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// Some browsers only show the prompt when returnValue is set
|
||||||
|
e.returnValue = ""
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handler)
|
||||||
|
return () => window.removeEventListener("beforeunload", handler)
|
||||||
|
}, [hasDirty])
|
||||||
|
|
||||||
|
// Highlight the section currently in view in the sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authedPassword) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||||
|
)
|
||||||
|
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||||
|
},
|
||||||
|
{ rootMargin: "-10% 0px -50% 0px" },
|
||||||
|
)
|
||||||
|
for (const id of NAV_GROUP_IDS) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
}
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [authedPassword])
|
||||||
|
|
||||||
|
// value undefined drops the pending change (back to the saved value)
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(key: string, value: string | null | undefined) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(key in prev)) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPending((prev) => {
|
||||||
|
const state = settings[key]
|
||||||
|
const isRevert =
|
||||||
|
value !== null &&
|
||||||
|
state?.source === "file" &&
|
||||||
|
!isSecretValue(state?.value) &&
|
||||||
|
value === savedTextOf(state)
|
||||||
|
const isNoop =
|
||||||
|
value === "" &&
|
||||||
|
(!state || state.source !== "file") &&
|
||||||
|
!isSecretValue(state?.value)
|
||||||
|
if (value === undefined || isRevert || isNoop) {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: value === "" ? null : value }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggling a group off stages deletion of its saved values so the
|
||||||
|
// feature actually turns off on save; toggling on drops those deletions.
|
||||||
|
const handleGroupToggle = useCallback(
|
||||||
|
(groupId: string, enabled: boolean) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||||
|
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||||
|
(d) => d.key,
|
||||||
|
)
|
||||||
|
setPending((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!enabled) {
|
||||||
|
// Stage deletion of saved values; drop unsaved input
|
||||||
|
if (settings[key]?.source === "default")
|
||||||
|
delete next[key]
|
||||||
|
else next[key] = null
|
||||||
|
} else if (next[key] === null) {
|
||||||
|
delete next[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!authedPassword || dirtyCount === 0) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveMessage(null)
|
||||||
|
setErrors({})
|
||||||
|
try {
|
||||||
|
if (providersDirty) {
|
||||||
|
const data = await adminFetch(
|
||||||
|
"/api/admin/providers",
|
||||||
|
authedPassword,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||||
|
)
|
||||||
|
applyProvidersResponse(data)
|
||||||
|
}
|
||||||
|
if (Object.keys(pending).length > 0) {
|
||||||
|
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-admin-password": authedPassword,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ values: pending }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
// Per-field validation errors come back as {errors: {...}}
|
||||||
|
if (data.errors) {
|
||||||
|
setErrors(data.errors)
|
||||||
|
const firstKey = Object.keys(data.errors)[0]
|
||||||
|
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||||
|
throw new Error(dict.admin.invalidSettings)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
data.error || `Request failed (${res.status})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applySettingsResponse(data)
|
||||||
|
setPending({})
|
||||||
|
}
|
||||||
|
setSaveMessage({
|
||||||
|
ok: true,
|
||||||
|
text: dict.admin.saved,
|
||||||
|
})
|
||||||
|
setTimeout(() => setSaveMessage(null), 4000)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveMessage({
|
||||||
|
ok: false,
|
||||||
|
text:
|
||||||
|
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
authedPassword,
|
||||||
|
pending,
|
||||||
|
providers,
|
||||||
|
providersDirty,
|
||||||
|
dirtyCount,
|
||||||
|
applySettingsResponse,
|
||||||
|
applyProvidersResponse,
|
||||||
|
dict,
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Login screen ─────────────────────────────────────────────────
|
||||||
|
if (!authedPassword) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void login(password)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LockKeyhole
|
||||||
|
className="h-5 w-5 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{dict.admin.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{dict.admin.loginPrompt}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="admin-password">
|
||||||
|
{dict.admin.password}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="admin-password"
|
||||||
|
name="admin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-destructive",
|
||||||
|
!authError && "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{authError}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={authLoading}
|
||||||
|
>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.signingIn}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
dict.admin.signIn
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings screen ──────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck
|
||||||
|
className="h-5 w-5 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{dict.admin.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.admin.precedence}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!writable && (
|
||||||
|
<div className="border-b bg-amber-500/10">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.notWritable}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||||
|
<nav
|
||||||
|
aria-label={dict.admin.settingGroups}
|
||||||
|
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||||
|
>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
aria-current={
|
||||||
|
activeGroup === item.id
|
||||||
|
? "true"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
activeGroup === item.id
|
||||||
|
? "bg-muted font-medium text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 pb-24">
|
||||||
|
{/* Models section */}
|
||||||
|
<section aria-labelledby="models" className="mb-10">
|
||||||
|
<h2
|
||||||
|
id="models"
|
||||||
|
className="scroll-mt-20 text-base font-semibold"
|
||||||
|
>
|
||||||
|
{dict.admin.models}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||||
|
{dict.admin.modelsDescription}
|
||||||
|
</p>
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-card">
|
||||||
|
<ModelsSection
|
||||||
|
providers={providers}
|
||||||
|
envProviders={envProviders}
|
||||||
|
envHasDefaultModel={envHasDefaultModel}
|
||||||
|
disabled={!writable || saving}
|
||||||
|
password={authedPassword}
|
||||||
|
onChange={(next) => {
|
||||||
|
setSaveMessage(null)
|
||||||
|
setProviders(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Registry-driven groups */}
|
||||||
|
{SETTING_GROUPS.map((group) => {
|
||||||
|
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||||
|
const groupOff =
|
||||||
|
group.toggleable && !enabledGroups[group.id]
|
||||||
|
// Values from env vars can't be removed here, so the
|
||||||
|
// group can't be turned off from the panel
|
||||||
|
const envLocked = defs.some(
|
||||||
|
(d) => settings[d.key]?.source === "env",
|
||||||
|
)
|
||||||
|
const fieldsDisabled = !writable || saving || !!groupOff
|
||||||
|
const gt = groupText(group.id)
|
||||||
|
const title = gt?.title ?? group.title
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={group.id}
|
||||||
|
aria-labelledby={group.id}
|
||||||
|
className="mb-10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2
|
||||||
|
id={group.id}
|
||||||
|
className="scroll-mt-20 text-base font-semibold"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{group.toggleable && (
|
||||||
|
<label
|
||||||
|
title={
|
||||||
|
envLocked
|
||||||
|
? dict.admin.sourceEnvTitle
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||||
|
enabledGroups[group.id]
|
||||||
|
? "border-primary/30 bg-primary/5 text-primary"
|
||||||
|
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enabledGroups[group.id]
|
||||||
|
? dict.admin.enabled
|
||||||
|
: dict.admin.disabled}
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
!!enabledGroups[group.id]
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!writable ||
|
||||||
|
saving ||
|
||||||
|
envLocked
|
||||||
|
}
|
||||||
|
aria-label={formatMessage(
|
||||||
|
dict.admin.enableGroup,
|
||||||
|
{ group: title },
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleGroupToggle(
|
||||||
|
group.id,
|
||||||
|
checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||||
|
{gt?.description ?? group.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card px-4",
|
||||||
|
groupOff &&
|
||||||
|
"pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{defs.map((def) => (
|
||||||
|
<SettingField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
state={settings[def.key]}
|
||||||
|
pendingValue={pending[def.key]}
|
||||||
|
error={errors[def.key]}
|
||||||
|
disabled={fieldsDisabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleChange(def.key, v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Always-mounted live region so save results are announced */}
|
||||||
|
<p aria-live="polite" className="sr-only">
|
||||||
|
{saveMessage?.text ?? ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(dirtyCount > 0 || saveMessage) && (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||||
|
saveMessage?.ok
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: saveMessage
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saveMessage?.ok && (
|
||||||
|
<Check
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{saveMessage && !saveMessage.ok
|
||||||
|
? saveMessage.text
|
||||||
|
: dirtyCount > 0
|
||||||
|
? dict.admin.unsavedChanges
|
||||||
|
: saveMessage?.text}
|
||||||
|
</p>
|
||||||
|
{dirtyCount > 0 && (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPending({})
|
||||||
|
setErrors({})
|
||||||
|
setProviders(JSON.parse(savedProviders))
|
||||||
|
setEnabledGroups(
|
||||||
|
groupsWithValues(settings),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dict.admin.discard}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !writable}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{dict.admin.saving}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
dict.admin.saveChanges
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { Eye, EyeOff, X } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
isSecretValue,
|
||||||
|
type SecretValue,
|
||||||
|
type SettingState,
|
||||||
|
savedTextOf,
|
||||||
|
} from "./admin-shared"
|
||||||
|
|
||||||
|
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
if (source === "default") return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||||
|
source === "file"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
source === "file"
|
||||||
|
? dict.admin.sourceSavedTitle
|
||||||
|
: dict.admin.sourceEnvTitle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartBadge() {
|
||||||
|
const dict = useDictionary()
|
||||||
|
return (
|
||||||
|
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||||
|
{dict.admin.restartRequired}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||||
|
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||||
|
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||||
|
export function SecretInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
keepOnEmpty,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: string | SecretValue | undefined
|
||||||
|
disabled?: boolean
|
||||||
|
keepOnEmpty?: boolean
|
||||||
|
onChange: (value: string | SecretValue) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||||
|
// passes server state (a marker or nothing), e.g. after a save.
|
||||||
|
const [original, setOriginal] = useState(value)
|
||||||
|
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||||
|
const hadStored = isSecretValue(original)
|
||||||
|
const text = typeof value === "string" ? value : ""
|
||||||
|
const placeholder = isSecretValue(value)
|
||||||
|
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||||
|
: dict.admin.notSet
|
||||||
|
const handleText = (t: string) => {
|
||||||
|
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||||
|
onChange(original)
|
||||||
|
} else {
|
||||||
|
onChange(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={show ? "text" : "password"}
|
||||||
|
value={text}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-9 font-mono text-xs"
|
||||||
|
onChange={(e) => handleText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||||
|
onClick={() => setShow((s) => !s)}
|
||||||
|
>
|
||||||
|
{show ? (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={dict.admin.removeValue}
|
||||||
|
title={dict.admin.removeValueTitle}
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── General settings field (registry-driven) ─────────────────────────
|
||||||
|
|
||||||
|
export function SettingField({
|
||||||
|
def,
|
||||||
|
state,
|
||||||
|
pendingValue,
|
||||||
|
error,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
def: SettingDef
|
||||||
|
state: SettingState | undefined
|
||||||
|
pendingValue: string | null | undefined
|
||||||
|
error?: string
|
||||||
|
disabled: boolean
|
||||||
|
// undefined drops the pending change (back to the saved value)
|
||||||
|
onChange: (value: string | null | undefined) => void
|
||||||
|
}) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const isDirty = pendingValue !== undefined
|
||||||
|
const source = state?.source ?? "default"
|
||||||
|
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||||
|
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||||
|
|
||||||
|
// Localized label/description keyed by env var name, falling back to the
|
||||||
|
// registry's English (the registry stays canonical for the server).
|
||||||
|
const t = (
|
||||||
|
dict.admin.settings as Record<
|
||||||
|
string,
|
||||||
|
{ label?: string; description?: string } | undefined
|
||||||
|
>
|
||||||
|
)[def.key]
|
||||||
|
const label = t?.label ?? def.label
|
||||||
|
const description = t?.description ?? def.description
|
||||||
|
|
||||||
|
const inputId = `setting-${def.key}`
|
||||||
|
const errorId = `${inputId}-error`
|
||||||
|
|
||||||
|
let control: React.ReactNode
|
||||||
|
switch (def.type) {
|
||||||
|
case "boolean": {
|
||||||
|
// When unset, reflect the built-in runtime default so the toggle
|
||||||
|
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||||
|
const effective =
|
||||||
|
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||||
|
// A saved boolean can be cleared back to its env/default value.
|
||||||
|
const canClear =
|
||||||
|
(isDirty && pendingValue !== null) || source === "file"
|
||||||
|
control = (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id={inputId}
|
||||||
|
checked={effective === "true"}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange(checked ? "true" : "false")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{canClear && !disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
{dict.admin.resetToDefault}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "enum":
|
||||||
|
control = (
|
||||||
|
<Select
|
||||||
|
value={currentValue || undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||||
|
<SelectValue placeholder={dict.admin.notSet} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{def.options?.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "secret":
|
||||||
|
control = (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||||
|
<SecretInput
|
||||||
|
id={inputId}
|
||||||
|
keepOnEmpty={source === "file"}
|
||||||
|
value={
|
||||||
|
isDirty
|
||||||
|
? (pendingValue ?? "")
|
||||||
|
: (secretState ?? undefined)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
onChange(typeof v === "string" ? v : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "number":
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={def.min}
|
||||||
|
max={def.max}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||||
|
className="w-full max-w-xs tabular-nums"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<SourceChip source={source} />
|
||||||
|
{def.restartRequired && <RestartBadge />}
|
||||||
|
{isDirty && (
|
||||||
|
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
{dict.admin.modified}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{control}
|
||||||
|
<p
|
||||||
|
id={errorId}
|
||||||
|
className={cn(
|
||||||
|
"text-xs text-destructive",
|
||||||
|
error ? "mt-1.5" : "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{error ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
-2
@@ -41,19 +41,24 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ lang: string }>
|
params: Promise<{ lang: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { lang: rawLang } = await params
|
const { lang: rawLang } = await params
|
||||||
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
const lang = (
|
||||||
|
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
||||||
|
) as Locale
|
||||||
|
|
||||||
// Default to English metadata
|
// Default to English metadata
|
||||||
const titles: Record<Locale, string> = {
|
const titles: Record<Locale, string> = {
|
||||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||||
|
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptions: Record<Locale, string> = {
|
const descriptions: Record<Locale, string> = {
|
||||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||||
|
"zh-Hant":
|
||||||
|
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -80,7 +85,14 @@ export async function generateMetadata({
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
locale:
|
||||||
|
lang === "zh"
|
||||||
|
? "zh_CN"
|
||||||
|
: lang === "zh-Hant"
|
||||||
|
? "zh_HK"
|
||||||
|
: lang === "ja"
|
||||||
|
? "ja_JP"
|
||||||
|
: "en_US",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
@@ -115,6 +127,7 @@ export async function generateMetadata({
|
|||||||
en: "/en",
|
en: "/en",
|
||||||
zh: "/zh",
|
zh: "/zh",
|
||||||
ja: "/ja",
|
ja: "/ja",
|
||||||
|
"zh-Hant": "/zh-Hant",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-54
@@ -4,35 +4,39 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
|||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
|
||||||
const drawioBaseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [closeProtection, setCloseProtection] = useState(false)
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
|
)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isMobileRef = useRef(false)
|
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,13 +47,15 @@ export default function Home() {
|
|||||||
const currentLocale = pathParts[0]
|
const currentLocale = pathParts[0]
|
||||||
if (currentLocale !== savedLocale) {
|
if (currentLocale !== savedLocale) {
|
||||||
pathParts[0] = savedLocale
|
pathParts[0] = savedLocale
|
||||||
router.replace(`/${pathParts.join("/")}`)
|
// Keep the query (e.g. ?session=) and hash
|
||||||
|
const { search, hash } = window.location
|
||||||
|
router.replace(`/${pathParts.join("/")}${search}${hash}`)
|
||||||
return // Wait for redirect
|
return // Wait for redirect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (savedUi === "min" || savedUi === "sketch") {
|
if (isDrawioTheme(savedUi)) {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +72,15 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedCloseProtection = localStorage.getItem(
|
// Detect Electron and use bundled draw.io files for offline use
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||||
)
|
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||||
if (savedCloseProtection === "true") {
|
const electronDetected =
|
||||||
setCloseProtection(true)
|
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||||
|
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||||
|
if (electronDetected) {
|
||||||
|
setIsElectron(true)
|
||||||
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
@@ -90,35 +100,32 @@ export default function Home() {
|
|||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawioUiChange = () => {
|
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
localStorage.setItem("drawio-theme", theme)
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
setDrawioUi(theme)
|
||||||
setDrawioUi(newUi)
|
|
||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - reset draw.io before crossing breakpoint
|
// Check mobile. No panel is remounted when crossing the breakpoint, so
|
||||||
const isInitialRenderRef = useRef(true)
|
// the draw.io ready state and the chat's turn stay as they are.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
const newIsMobile = window.innerWidth < 768
|
setIsMobile(window.innerWidth < 768)
|
||||||
if (
|
|
||||||
!isInitialRenderRef.current &&
|
|
||||||
newIsMobile !== isMobileRef.current
|
|
||||||
) {
|
|
||||||
setIsDrawioReady(false)
|
|
||||||
resetDrawioReady()
|
|
||||||
}
|
|
||||||
isMobileRef.current = newIsMobile
|
|
||||||
isInitialRenderRef.current = false
|
|
||||||
setIsMobile(newIsMobile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [resetDrawioReady])
|
}, [])
|
||||||
|
|
||||||
|
// Give the chat panel the size of this side of the breakpoint. It is
|
||||||
|
// open on both sides: the mobile panel cannot be collapsed, and one
|
||||||
|
// collapsed on desktop comes back open
|
||||||
|
useEffect(() => {
|
||||||
|
chatPanelRef.current?.resize(isMobile ? 50 : 33)
|
||||||
|
setIsChatVisible(true)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -146,20 +153,6 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Show confirmation dialog when user tries to leave the page
|
|
||||||
useEffect(() => {
|
|
||||||
if (!closeProtection) return
|
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
}, [closeProtection])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -183,8 +176,10 @@ export default function Home() {
|
|||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
>
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
|
autosave
|
||||||
|
onAutoSave={handleDiagramAutoSave}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
@@ -195,8 +190,17 @@ export default function Home() {
|
|||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark:
|
||||||
lang: currentLang,
|
darkMode || drawioUi === "dark",
|
||||||
|
// draw.io names Traditional Chinese "zh-tw"
|
||||||
|
lang:
|
||||||
|
currentLang === "zh-Hant"
|
||||||
|
? "zh-tw"
|
||||||
|
: currentLang,
|
||||||
|
// Enable offline mode in Electron to disable external service calls
|
||||||
|
...(isElectron && {
|
||||||
|
offline: true,
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +220,6 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
key={isMobile ? "mobile" : "desktop"}
|
|
||||||
id="chat-panel"
|
id="chat-panel"
|
||||||
ref={chatPanelRef}
|
ref={chatPanelRef}
|
||||||
defaultSize={isMobile ? 50 : 33}
|
defaultSize={isMobile ? 50 : 33}
|
||||||
@@ -239,11 +242,10 @@ export default function Home() {
|
|||||||
isVisible={isChatVisible}
|
isVisible={isChatVisible}
|
||||||
onToggleVisibility={toggleChatPanel}
|
onToggleVisibility={toggleChatPanel}
|
||||||
drawioUi={drawioUi}
|
drawioUi={drawioUi}
|
||||||
onToggleDrawioUi={handleDrawioUiChange}
|
onDrawioUiChange={handleDrawioUiChange}
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onCloseProtectionChange={setCloseProtection}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
AdminProvidersSchema,
|
||||||
|
deriveEnvUpdates,
|
||||||
|
loadAdminProviders,
|
||||||
|
maskAdminProviders,
|
||||||
|
mergeSecrets,
|
||||||
|
validateAdminProviders,
|
||||||
|
} from "@/lib/admin/providers"
|
||||||
|
import {
|
||||||
|
getEnvFallback,
|
||||||
|
isSettingsWritable,
|
||||||
|
saveSettings,
|
||||||
|
} from "@/lib/admin/settings"
|
||||||
|
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
async function payload() {
|
||||||
|
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
||||||
|
// read-only in the panel; their credentials live in the environment
|
||||||
|
const envConfig = await loadEnvServerModelsConfig()
|
||||||
|
const adminProviders = loadAdminProviders()
|
||||||
|
// A panel default overrides any env default (matches the merge in
|
||||||
|
// loadRawServerModelsConfig), so env stars must reflect that
|
||||||
|
const adminHasDefault = adminProviders.some(
|
||||||
|
(p) => p.isDefault && p.models.length > 0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
writable: isSettingsWritable(),
|
||||||
|
providers: maskAdminProviders(adminProviders),
|
||||||
|
envProviders:
|
||||||
|
envConfig?.providers.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
provider: p.provider,
|
||||||
|
models: p.models,
|
||||||
|
isDefault: !!p.default && !adminHasDefault,
|
||||||
|
})) ?? [],
|
||||||
|
// Whether .env sets a default model. getEnvFallback skips the value
|
||||||
|
// the panel overlays onto process.env, so a panel default doesn't count.
|
||||||
|
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
return Response.json(await payload())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
if (!isSettingsWritable()) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AdminProvidersSchema.safeParse(
|
||||||
|
(body as { providers?: unknown })?.providers,
|
||||||
|
)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = loadAdminProviders()
|
||||||
|
const merged = mergeSecrets(parsed.data, stored)
|
||||||
|
|
||||||
|
const envConfig = await loadEnvServerModelsConfig()
|
||||||
|
const validationError = validateAdminProviders(merged, envConfig)
|
||||||
|
if (validationError) {
|
||||||
|
return Response.json({ error: validationError }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(deriveEnvUpdates(merged, stored))
|
||||||
|
|
||||||
|
return Response.json(await payload())
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
getEnvFallback,
|
||||||
|
getValueSource,
|
||||||
|
isSettingsWritable,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
} from "@/lib/admin/settings"
|
||||||
|
import {
|
||||||
|
SETTINGS_BY_KEY,
|
||||||
|
SETTINGS_REGISTRY,
|
||||||
|
type SettingDef,
|
||||||
|
} from "@/lib/admin/settings-registry"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
function serializeSettings() {
|
||||||
|
const fileValues = loadSettings()
|
||||||
|
return SETTINGS_REGISTRY.map((def) => {
|
||||||
|
const source = getValueSource(def.key)
|
||||||
|
const raw =
|
||||||
|
source === "file"
|
||||||
|
? fileValues[def.key]
|
||||||
|
: (getEnvFallback(def.key) ?? null)
|
||||||
|
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
|
||||||
|
return { key: def.key, source, value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
writable: isSettingsWritable(),
|
||||||
|
settings: serializeSettings(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateValue(def: SettingDef, value: string): string | null {
|
||||||
|
switch (def.type) {
|
||||||
|
case "number": {
|
||||||
|
const num = Number(value)
|
||||||
|
if (!Number.isFinite(num)) return "Must be a number"
|
||||||
|
if (def.min !== undefined && num < def.min)
|
||||||
|
return `Must be at least ${def.min}`
|
||||||
|
if (def.max !== undefined && num > def.max)
|
||||||
|
return `Must be at most ${def.max}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
return value === "true" || value === "false"
|
||||||
|
? null
|
||||||
|
: 'Must be "true" or "false"'
|
||||||
|
case "enum":
|
||||||
|
return def.options?.includes(value)
|
||||||
|
? null
|
||||||
|
: `Must be one of: ${def.options?.join(", ")}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
if (!isSettingsWritable()) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { values?: Record<string, unknown> }
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!body.values || typeof body.values !== "object") {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Body must contain a values object" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, string | null> = {}
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(body.values)) {
|
||||||
|
const def = SETTINGS_BY_KEY.get(key)
|
||||||
|
if (!def) {
|
||||||
|
errors[key] = "Unknown setting"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (value === null || value === "") {
|
||||||
|
updates[key] = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
errors[key] = "Value must be a string"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const error = validateValue(def, value)
|
||||||
|
if (error) {
|
||||||
|
errors[key] = error
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updates[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return Response.json({ errors }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(updates)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
writable: true,
|
||||||
|
settings: serializeSettings(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
AdminProviderSchema,
|
||||||
|
loadAdminProviders,
|
||||||
|
mergeSecrets,
|
||||||
|
} from "@/lib/admin/providers"
|
||||||
|
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
// Test a model with the client's CURRENT provider state (which may be
|
||||||
|
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
||||||
|
// masked {isSet} markers, which are resolved against settings.json — so
|
||||||
|
// testing works both before and after saving.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const authError = checkAdminAuth(req)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
let body: { provider?: unknown; modelId?: string }
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AdminProviderSchema.safeParse(body.provider)
|
||||||
|
if (!parsed.success || !body.modelId) {
|
||||||
|
return Response.json(
|
||||||
|
{ valid: false, error: "Invalid provider or model" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
||||||
|
// the endpoint it would be sent to (provider + baseUrl) still matches
|
||||||
|
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
||||||
|
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
||||||
|
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
||||||
|
const sameEndpoint =
|
||||||
|
stored &&
|
||||||
|
stored.provider === parsed.data.provider &&
|
||||||
|
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
||||||
|
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
||||||
|
const [resolved] = mergeSecrets(
|
||||||
|
[parsed.data],
|
||||||
|
sameEndpoint && stored ? [stored] : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const serverUrl = globalBaseUrl(resolved.provider)
|
||||||
|
return validateModel(
|
||||||
|
new Request(new URL("/api/validate-model", req.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Checked again there, in place of an access code
|
||||||
|
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||||
|
// The EdgeOne function checks the access code and Pages
|
||||||
|
// cookies, and its URL is built from the page's origin
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
cookie: req.headers.get("cookie") || "",
|
||||||
|
...(req.headers.get("origin") && {
|
||||||
|
origin: req.headers.get("origin") as string,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: resolved.provider,
|
||||||
|
apiKey: resolved.apiKey,
|
||||||
|
// Without a URL of its own, chat sends the entry's key to
|
||||||
|
// the server's <P>_BASE_URL: test that endpoint, not
|
||||||
|
// another one. It is the server's own, which chat uses
|
||||||
|
// without the checks for a URL a user typed.
|
||||||
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
|
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||||
|
modelId: body.modelId,
|
||||||
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
|
awsRegion: resolved.awsRegion,
|
||||||
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
+428
-369
@@ -4,40 +4,66 @@ import {
|
|||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
createUIMessageStreamResponse,
|
createUIMessageStreamResponse,
|
||||||
InvalidToolInputError,
|
InvalidToolInputError,
|
||||||
LoadAPIKeyError,
|
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
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 { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import {
|
import {
|
||||||
|
CACHE_POINT,
|
||||||
|
edgeOneEndpoint,
|
||||||
getAIModel,
|
getAIModel,
|
||||||
supportsImageInput,
|
getServerProvider,
|
||||||
|
SINGLE_SYSTEM_PROVIDERS,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
|
usesServerCredentials,
|
||||||
|
usesServerEndpoint,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
import {
|
||||||
isMinimalDiagram,
|
dropInvalidToolCalls,
|
||||||
|
fixToolInputJson,
|
||||||
replaceHistoricalToolInputs,
|
replaceHistoricalToolInputs,
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
|
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
} from "@/lib/dynamo-quota-manager"
|
} from "@/lib/dynamo-quota-manager"
|
||||||
import {
|
import {
|
||||||
|
endTrace,
|
||||||
getTelemetryConfig,
|
getTelemetryConfig,
|
||||||
setTraceInput,
|
setTraceInput,
|
||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
|
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
|
||||||
|
import {
|
||||||
|
resolveMaxOutputTokens,
|
||||||
|
withOutputTokenLimitFallback,
|
||||||
|
} from "@/lib/output-token-limit"
|
||||||
|
import {
|
||||||
|
type FlattenedServerModel,
|
||||||
|
findServerModelById,
|
||||||
|
} from "@/lib/server-model-config"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
|
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
import {
|
||||||
|
getShapeLibrary,
|
||||||
|
SHAPE_LIBRARY_LIST,
|
||||||
|
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||||
|
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
||||||
|
|
||||||
export const maxDuration = 120
|
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||||
|
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||||
|
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
@@ -69,26 +95,25 @@ function createCachedStreamResponse(xml: string): Response {
|
|||||||
return createUIMessageStreamResponse({ stream })
|
return createUIMessageStreamResponse({ stream })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner handler function
|
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
const modelStreamResponses = new WeakSet<Response>()
|
||||||
// Check for access code
|
|
||||||
const accessCodes =
|
|
||||||
process.env.ACCESS_CODE_LIST?.split(",")
|
|
||||||
.map((code) => code.trim())
|
|
||||||
.filter(Boolean) || []
|
|
||||||
if (accessCodes.length > 0) {
|
|
||||||
const accessCodeHeader = req.headers.get("x-access-code")
|
|
||||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
// Inner handler function
|
||||||
|
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||||
|
|
||||||
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Check for access code
|
||||||
|
const accessDenied = checkAccessCode(req)
|
||||||
|
if (accessDenied) return accessDenied
|
||||||
|
|
||||||
|
const 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)
|
||||||
@@ -114,14 +139,165 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
userId: userId,
|
userId: userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// === SERVER-SIDE QUOTA CHECK START ===
|
// === FILE VALIDATION START ===
|
||||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
const fileValidation = validateFileParts(messages)
|
||||||
const hasOwnApiKey = !!(
|
if (!fileValidation.valid) {
|
||||||
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
return Response.json({ error: fileValidation.error }, { status: 400 })
|
||||||
|
}
|
||||||
|
// === FILE VALIDATION END ===
|
||||||
|
|
||||||
|
// === CACHE CHECK START ===
|
||||||
|
const isFirstMessage = messages.length === 1
|
||||||
|
const isEmptyDiagram = !xml || !hasCells(xml)
|
||||||
|
|
||||||
|
if (isFirstMessage && isEmptyDiagram) {
|
||||||
|
const lastMessage = messages[0]
|
||||||
|
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
||||||
|
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
||||||
|
|
||||||
|
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return createCachedStreamResponse(cached.xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// === CACHE CHECK END ===
|
||||||
|
|
||||||
|
// Read client AI provider overrides from headers
|
||||||
|
const provider = req.headers.get("x-ai-provider")
|
||||||
|
let baseUrl = req.headers.get("x-ai-base-url")
|
||||||
|
const selectedModelId = req.headers.get("x-selected-model-id")
|
||||||
|
|
||||||
|
// Check if this is a server model with custom env var names
|
||||||
|
let serverModelConfig: {
|
||||||
|
apiKeyEnv?: string | string[]
|
||||||
|
baseUrlEnv?: string
|
||||||
|
provider?: string
|
||||||
|
} = {}
|
||||||
|
let serverModel: FlattenedServerModel | null = null
|
||||||
|
if (selectedModelId?.startsWith("server:")) {
|
||||||
|
serverModel = await findServerModelById(selectedModelId)
|
||||||
|
console.log(
|
||||||
|
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
||||||
|
)
|
||||||
|
if (serverModel) {
|
||||||
|
serverModelConfig = {
|
||||||
|
apiKeyEnv: serverModel.apiKeyEnv,
|
||||||
|
baseUrlEnv: serverModel.baseUrlEnv,
|
||||||
|
// Use actual provider from config (client header may have incorrect value due to ID format change)
|
||||||
|
provider: serverModel.provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server model's provider comes from its config: for one set up in
|
||||||
|
// the admin panel the header holds the provider name's slug. Without
|
||||||
|
// either, the server's own AI_PROVIDER.
|
||||||
|
const isEdgeOne =
|
||||||
|
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||||
|
"edgeone"
|
||||||
|
|
||||||
|
// EdgeOne is this deployment's own function, whatever URL the request
|
||||||
|
// names: another host would get the user's EdgeOne cookies, and the
|
||||||
|
// quota counts it. Absolute, as the SDK needs.
|
||||||
|
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||||
|
|
||||||
|
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||||
|
// not point the server at a private or internal address
|
||||||
|
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Private or internal base URLs are not allowed." },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||||
|
const cookieHeader = req.headers.get("cookie")
|
||||||
|
|
||||||
|
const clientOverrides = {
|
||||||
|
// Server model provider takes precedence over client header; EdgeOne
|
||||||
|
// named only in AI_PROVIDER is named here, for its own base URL
|
||||||
|
provider:
|
||||||
|
serverModelConfig.provider ||
|
||||||
|
provider ||
|
||||||
|
(isEdgeOne ? "edgeone" : null),
|
||||||
|
baseUrl,
|
||||||
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
|
// A server model runs the model it was configured with, whatever the header says
|
||||||
|
modelId: serverModel?.modelId || req.headers.get("x-ai-model"),
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||||
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
|
// Server model custom env var names
|
||||||
|
...serverModelConfig,
|
||||||
|
// Vertex AI credentials (Express Mode)
|
||||||
|
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||||
|
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||||
|
// which the EdgeOne function checks too
|
||||||
|
...(isEdgeOne && {
|
||||||
|
headers: {
|
||||||
|
...(cookieHeader && { cookie: cookieHeader }),
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read minimal style preference from header
|
||||||
|
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
// Get AI model with optional client overrides
|
||||||
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
|
const {
|
||||||
|
model: baseModel,
|
||||||
|
providerOptions,
|
||||||
|
modelId,
|
||||||
|
provider: resolvedProvider,
|
||||||
|
} = getAIModel(clientOverrides)
|
||||||
|
|
||||||
|
// On the server's own keys, only run models the server offers: a server
|
||||||
|
// model picked by id (its model name is fixed above) or one in AI_MODEL
|
||||||
|
// on AI_PROVIDER. With their own key, users can run any model.
|
||||||
|
const onServerCredentials = usesServerCredentials(
|
||||||
|
resolvedProvider,
|
||||||
|
clientOverrides,
|
||||||
|
)
|
||||||
|
const envModels =
|
||||||
|
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
|
||||||
|
const offeredInEnv =
|
||||||
|
envModels.includes(modelId) && resolvedProvider === getServerProvider()
|
||||||
|
if (onServerCredentials && !serverModel && !offeredInEnv) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SERVER-SIDE QUOTA CHECK START ===
|
||||||
|
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
|
||||||
|
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
|
||||||
|
// Ollama, and anything at a private address (the server's network,
|
||||||
|
// which ignores a dummy key header). Bedrock and EdgeOne never use the
|
||||||
|
// base URL header. In the desktop app every endpoint is the user's.
|
||||||
|
const clientBaseUrl = normalizeBaseUrl(
|
||||||
|
req.headers.get("x-ai-base-url") ?? "",
|
||||||
|
)
|
||||||
|
const onServerEndpoint = await usesServerEndpoint(
|
||||||
|
resolvedProvider,
|
||||||
|
clientBaseUrl,
|
||||||
|
clientOverrides.apiKey,
|
||||||
|
)
|
||||||
|
const countsQuota =
|
||||||
|
isQuotaEnabled() &&
|
||||||
|
(onServerCredentials || onServerEndpoint) &&
|
||||||
|
userId !== "anonymous"
|
||||||
|
if (countsQuota) {
|
||||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
@@ -141,67 +317,20 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
// === SERVER-SIDE QUOTA CHECK END ===
|
// === SERVER-SIDE QUOTA CHECK END ===
|
||||||
|
|
||||||
// === FILE VALIDATION START ===
|
// Retry once if the provider rejects the requested budget, or (newer
|
||||||
const fileValidation = validateFileParts(messages)
|
// Claude models) the sampling or thinking settings
|
||||||
if (!fileValidation.valid) {
|
const model = withOutputTokenLimitFallback(
|
||||||
return Response.json({ error: fileValidation.error }, { status: 400 })
|
withDeprecatedParamsFallback(baseModel),
|
||||||
}
|
)
|
||||||
// === FILE VALIDATION END ===
|
|
||||||
|
|
||||||
// === CACHE CHECK START ===
|
// The user setting can raise the budget only on their own key (in the
|
||||||
const isFirstMessage = messages.length === 1
|
// desktop app every key is the user's); on the server's keys or own
|
||||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
// endpoints it can only lower it
|
||||||
|
const maxOutputTokens = resolveMaxOutputTokens(
|
||||||
if (isFirstMessage && isEmptyDiagram) {
|
req.headers.get("x-max-output-tokens"),
|
||||||
const lastMessage = messages[0]
|
onServerCredentials || onServerEndpoint,
|
||||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
)
|
||||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||||
|
|
||||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return createCachedStreamResponse(cached.xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// === CACHE CHECK END ===
|
|
||||||
|
|
||||||
// Read client AI provider overrides from headers
|
|
||||||
const provider = req.headers.get("x-ai-provider")
|
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
|
||||||
|
|
||||||
// For EdgeOne provider, construct full URL from request origin
|
|
||||||
// because createOpenAI needs absolute URL, not relative path
|
|
||||||
if (provider === "edgeone" && !baseUrl) {
|
|
||||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
|
||||||
baseUrl = `${origin}/api/edgeai`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
|
||||||
const cookieHeader = req.headers.get("cookie")
|
|
||||||
|
|
||||||
const clientOverrides = {
|
|
||||||
provider,
|
|
||||||
baseUrl,
|
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
|
||||||
modelId: req.headers.get("x-ai-model"),
|
|
||||||
// AWS Bedrock credentials
|
|
||||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
|
||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
|
||||||
// Pass cookies for EdgeOne Pages authentication
|
|
||||||
...(provider === "edgeone" &&
|
|
||||||
cookieHeader && {
|
|
||||||
headers: { cookie: cookieHeader },
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read minimal style preference from header
|
|
||||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
|
||||||
|
|
||||||
// Get AI model with optional client overrides
|
|
||||||
const { model, providerOptions, headers, modelId } =
|
|
||||||
getAIModel(clientOverrides)
|
|
||||||
|
|
||||||
// Check if model supports prompt caching
|
// Check if model supports prompt caching
|
||||||
const shouldCache = supportsPromptCaching(modelId)
|
const shouldCache = supportsPromptCaching(modelId)
|
||||||
@@ -211,22 +340,19 @@ 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
|
// Note: we used to pre-emptively reject images for models we guessed were
|
||||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
// text-only (by name matching). That heuristic misfired on newer models
|
||||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
// (see issue #874), so we now let the request through and surface the real
|
||||||
return Response.json(
|
// provider error if the model genuinely can't accept images.
|
||||||
{
|
|
||||||
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:
|
||||||
@@ -234,39 +360,46 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
${userInputText}
|
${userInputText}
|
||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message
|
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||||
const modelMessages = await convertToModelMessages(messages)
|
// call that never got its result (the user stopped while it ran) is
|
||||||
|
// left out: the SDK would refuse this and every later request of the
|
||||||
// DEBUG: Log incoming messages structure
|
// chat (MissingToolResultsError)
|
||||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
const modelMessages = await convertToModelMessages(messages, {
|
||||||
messages.forEach((msg: any, idx: number) => {
|
ignoreIncompleteToolCalls: true,
|
||||||
console.log(
|
|
||||||
`[route.ts] Message ${idx} role:`,
|
|
||||||
msg.role,
|
|
||||||
"parts count:",
|
|
||||||
msg.parts?.length,
|
|
||||||
)
|
|
||||||
if (msg.parts) {
|
|
||||||
msg.parts.forEach((part: any, partIdx: number) => {
|
|
||||||
if (
|
|
||||||
part.type === "tool-invocation" ||
|
|
||||||
part.type === "tool-result"
|
|
||||||
) {
|
|
||||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
|
||||||
type: part.type,
|
|
||||||
toolName: part.toolName,
|
|
||||||
hasInput: !!part.input,
|
|
||||||
inputType: typeof part.input,
|
|
||||||
inputKeys:
|
|
||||||
part.input && typeof part.input === "object"
|
|
||||||
? Object.keys(part.input)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||||
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
|
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||||
|
messages.forEach((msg: any, idx: number) => {
|
||||||
|
console.log(
|
||||||
|
`[route.ts] Message ${idx} role:`,
|
||||||
|
msg.role,
|
||||||
|
"parts count:",
|
||||||
|
msg.parts?.length,
|
||||||
|
)
|
||||||
|
if (msg.parts) {
|
||||||
|
msg.parts.forEach((part: any, partIdx: number) => {
|
||||||
|
if (
|
||||||
|
part.type === "tool-invocation" ||
|
||||||
|
part.type === "tool-result"
|
||||||
|
) {
|
||||||
|
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||||
|
type: part.type,
|
||||||
|
toolName: part.toolName,
|
||||||
|
hasInput: !!part.input,
|
||||||
|
inputType: typeof part.input,
|
||||||
|
inputKeys:
|
||||||
|
part.input && typeof part.input === "object"
|
||||||
|
? Object.keys(part.input)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Replace historical tool call XML with placeholders to reduce tokens
|
// Replace historical tool call XML with placeholders to reduce tokens
|
||||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
||||||
const enableHistoryReplace =
|
const enableHistoryReplace =
|
||||||
@@ -283,61 +416,43 @@ ${userInputText}
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||||
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
|
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||||
enhancedMessages = enhancedMessages
|
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||||
.map((msg: any) => {
|
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
const filteredContent = msg.content.filter((part: any) => {
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
// Check if input is a valid object (not null, undefined, or empty)
|
|
||||||
if (
|
|
||||||
!part.input ||
|
|
||||||
typeof part.input !== "object" ||
|
|
||||||
Object.keys(part.input).length === 0
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`[route.ts] Filtering out tool-call with invalid input:`,
|
|
||||||
{ toolName: part.toolName, input: part.input },
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return { ...msg, content: filteredContent }
|
|
||||||
})
|
|
||||||
.filter((msg: any) => msg.content && msg.content.length > 0)
|
|
||||||
|
|
||||||
// DEBUG: Log modelMessages structure (what's being sent to AI)
|
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||||
console.log(
|
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||||
`[route.ts] ModelMsg ${idx} role:`,
|
console.log(
|
||||||
msg.role,
|
`[route.ts] ModelMsg ${idx} role:`,
|
||||||
"content count:",
|
msg.role,
|
||||||
msg.content?.length,
|
"content count:",
|
||||||
)
|
msg.content?.length,
|
||||||
if (msg.content) {
|
)
|
||||||
msg.content.forEach((part: any, partIdx: number) => {
|
if (msg.content) {
|
||||||
if (part.type === "tool-call" || part.type === "tool-result") {
|
msg.content.forEach((part: any, partIdx: number) => {
|
||||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
if (
|
||||||
type: part.type,
|
part.type === "tool-call" ||
|
||||||
toolName: part.toolName,
|
part.type === "tool-result"
|
||||||
hasInput: !!part.input,
|
) {
|
||||||
inputType: typeof part.input,
|
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||||
inputValue:
|
type: part.type,
|
||||||
part.input === undefined
|
toolName: part.toolName,
|
||||||
? "undefined"
|
hasInput: !!part.input,
|
||||||
: part.input === null
|
inputType: typeof part.input,
|
||||||
? "null"
|
inputValue:
|
||||||
: "object",
|
part.input === undefined
|
||||||
})
|
? "undefined"
|
||||||
}
|
: part.input === null
|
||||||
})
|
? "null"
|
||||||
}
|
: "object",
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Update the last message with user input only (XML moved to separate cached system message)
|
// Update the last message with user input only (XML moved to separate cached system message)
|
||||||
if (enhancedMessages.length >= 1) {
|
if (enhancedMessages.length >= 1) {
|
||||||
@@ -353,7 +468,7 @@ ${userInputText}
|
|||||||
contentParts.push({
|
contentParts.push({
|
||||||
type: "image",
|
type: "image",
|
||||||
image: filePart.url,
|
image: filePart.url,
|
||||||
mimeType: filePart.mediaType,
|
mediaType: filePart.mediaType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,9 +488,7 @@ ${userInputText}
|
|||||||
if (enhancedMessages[i].role === "assistant") {
|
if (enhancedMessages[i].role === "assistant") {
|
||||||
enhancedMessages[i] = {
|
enhancedMessages[i] = {
|
||||||
...enhancedMessages[i],
|
...enhancedMessages[i],
|
||||||
providerOptions: {
|
providerOptions: CACHE_POINT,
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
break // Only cache the last assistant message
|
break // Only cache the last assistant message
|
||||||
}
|
}
|
||||||
@@ -383,40 +496,75 @@ ${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.`
|
||||||
|
|
||||||
|
const systemMessages = isSingleSystemProvider
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: `${finalSystemMessage}\n\n${xmlContext}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Cache breakpoint 1: Instructions (+ optional custom instructions)
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: finalSystemMessage,
|
||||||
|
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||||
|
},
|
||||||
|
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: xmlContext,
|
||||||
|
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||||
|
|
||||||
|
// Set by onAbort, which records the finished steps' tokens itself
|
||||||
|
let stopped = false
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
// The system messages carry cache points, so they go in messages.
|
||||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
// A client's own system messages have string content and were
|
||||||
}),
|
// dropped by the empty-content filter above.
|
||||||
|
allowSystemInMessages: true,
|
||||||
|
abortSignal: req.signal,
|
||||||
|
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||||
|
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||||
|
maxOutputTokens,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||||
@@ -434,16 +582,11 @@ ${userInputText}
|
|||||||
error.name === "AI_InvalidToolInputError"
|
error.name === "AI_InvalidToolInputError"
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
|
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
|
||||||
let inputToRepair = toolCall.input
|
// then use jsonrepair to fix truncated JSON
|
||||||
if (typeof inputToRepair === "string") {
|
const repairedInput = jsonrepair(
|
||||||
// Fix `:=` instead of `: ` (LLM sometimes generates this)
|
fixToolInputJson(toolCall.input),
|
||||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
)
|
||||||
// Fix `= "` instead of `: "`
|
|
||||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
|
||||||
}
|
|
||||||
// Use jsonrepair to fix truncated JSON
|
|
||||||
const repairedInput = jsonrepair(inputToRepair)
|
|
||||||
console.log(
|
console.log(
|
||||||
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
|
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
|
||||||
)
|
)
|
||||||
@@ -453,26 +596,8 @@ ${userInputText}
|
|||||||
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
|
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
|
||||||
repairError,
|
repairError,
|
||||||
)
|
)
|
||||||
// Return a placeholder input to avoid API errors in multi-step
|
// Keep the original error, so the model and the client see why
|
||||||
// The tool will fail gracefully on client side
|
// the input was rejected and the model can retry the call
|
||||||
if (toolCall.toolName === "edit_diagram") {
|
|
||||||
return {
|
|
||||||
...toolCall,
|
|
||||||
input: {
|
|
||||||
operations: [],
|
|
||||||
_error: "JSON repair failed - no operations to apply",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (toolCall.toolName === "display_diagram") {
|
|
||||||
return {
|
|
||||||
...toolCall,
|
|
||||||
input: {
|
|
||||||
xml: "",
|
|
||||||
_error: "JSON repair failed - empty diagram",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,7 +606,6 @@ ${userInputText}
|
|||||||
},
|
},
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||||
...(headers && { headers }),
|
|
||||||
// Langfuse telemetry config (returns undefined if not configured)
|
// Langfuse telemetry config (returns undefined if not configured)
|
||||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||||
experimental_telemetry: getTelemetryConfig({
|
experimental_telemetry: getTelemetryConfig({
|
||||||
@@ -495,21 +619,36 @@ ${userInputText}
|
|||||||
|
|
||||||
// Record token usage for server-side quota tracking (if enabled)
|
// Record token usage for server-side quota tracking (if enabled)
|
||||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||||
// Include all 4 token types: input, output, cache read, cache write
|
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||||
if (
|
if (countsQuota && totalUsage && !stopped) {
|
||||||
isQuotaEnabled() &&
|
|
||||||
!hasOwnApiKey &&
|
|
||||||
userId !== "anonymous" &&
|
|
||||||
totalUsage
|
|
||||||
) {
|
|
||||||
const totalTokens =
|
const totalTokens =
|
||||||
(totalUsage.inputTokens || 0) +
|
(totalUsage.inputTokens || 0) +
|
||||||
(totalUsage.outputTokens || 0) +
|
(totalUsage.outputTokens || 0)
|
||||||
(totalUsage.cachedInputTokens || 0) +
|
|
||||||
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
|
||||||
recordTokenUsage(userId, totalTokens)
|
recordTokenUsage(userId, totalTokens)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||||
|
onError: ({ error }) => {
|
||||||
|
console.error(error) // what AI SDK does without an onError
|
||||||
|
endTrace()
|
||||||
|
},
|
||||||
|
onAbort: ({ steps }) => {
|
||||||
|
stopped = true
|
||||||
|
endTrace()
|
||||||
|
// Stopped (or disconnected) after some steps finished: their
|
||||||
|
// tokens were used, or stopping every request after a costly
|
||||||
|
// first step would get around the token limits
|
||||||
|
if (countsQuota) {
|
||||||
|
const tokens = steps.reduce(
|
||||||
|
(sum, step) =>
|
||||||
|
sum +
|
||||||
|
(step.usage.inputTokens || 0) +
|
||||||
|
(step.usage.outputTokens || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||||
|
}
|
||||||
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
display_diagram: {
|
display_diagram: {
|
||||||
@@ -524,21 +663,7 @@ VALIDATION RULES (XML will be rejected if violated):
|
|||||||
6. Escape special chars in values: < > & "
|
6. Escape special chars in values: < > & "
|
||||||
|
|
||||||
Example (generate ONLY this - no wrapper tags):
|
Example (generate ONLY this - no wrapper tags):
|
||||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
${SWIMLANE_EXAMPLE}
|
||||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- For AWS diagrams, use **AWS 2025 icons**.
|
- For AWS diagrams, use **AWS 2025 icons**.
|
||||||
@@ -616,14 +741,7 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
|
|||||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||||
|
|
||||||
Available libraries:
|
Available libraries:
|
||||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
${SHAPE_LIBRARY_LIST}
|
||||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
|
||||||
- Business: bpmn, lean_mapping
|
|
||||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
|
||||||
- UI/Mockups: android
|
|
||||||
- Enterprise: citrix, sap, mscae, atlassian
|
|
||||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
|
||||||
- Icons: webicons
|
|
||||||
|
|
||||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
@@ -634,45 +752,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ library }) => {
|
execute: async ({ library }) => {
|
||||||
// Sanitize input - prevent path traversal attacks
|
// Only known library names reach the file system
|
||||||
const sanitizedLibrary = library
|
const result = await getShapeLibrary(
|
||||||
.toLowerCase()
|
library,
|
||||||
.replace(/[^a-z0-9_-]/g, "")
|
path.join(process.cwd(), "docs/shape-libraries"),
|
||||||
|
|
||||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
|
||||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseDir = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"docs/shape-libraries",
|
|
||||||
)
|
)
|
||||||
const filePath = path.join(
|
return result.ok ? result.text : result.error
|
||||||
baseDir,
|
|
||||||
`${sanitizedLibrary}.md`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verify path stays within expected directory
|
|
||||||
const resolvedPath = path.resolve(filePath)
|
|
||||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
|
||||||
return `Invalid library path.`
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
|
||||||
return content
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
(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`
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
`[get_shape_library] Error loading "${library}":`,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return `Error loading library "${library}". Please try again.`
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -681,8 +766,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse({
|
const response = result.toUIMessageStreamResponse({
|
||||||
sendReasoning: true,
|
sendReasoning: true,
|
||||||
|
// On the server's keys the provider's text can name its account.
|
||||||
|
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||||
|
// user's own, and EdgeOne's text is our function's explanation.
|
||||||
|
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||||
messageMetadata: ({ part }) => {
|
messageMetadata: ({ part }) => {
|
||||||
if (part.type === "finish") {
|
if (part.type === "finish") {
|
||||||
const usage = (part as any).totalUsage
|
const usage = (part as any).totalUsage
|
||||||
@@ -695,63 +784,28 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
modelStreamResponses.add(response)
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to categorize errors and return appropriate response
|
// Errors before the stream starts, as JSON the chat panel reads
|
||||||
function handleError(error: unknown): Response {
|
function handleError(error: unknown): Response {
|
||||||
console.error("Error in chat route:", error)
|
console.error("Error in chat route:", error)
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
|
const classified = classifyLLMError(error)
|
||||||
// Check for specific AI SDK error types
|
const status =
|
||||||
if (APICallError.isInstance(error)) {
|
(error as { statusCode?: number })?.statusCode ||
|
||||||
return Response.json(
|
(error as { status?: number })?.status ||
|
||||||
{
|
(classified.code === "invalid_api_key" ? 401 : 500)
|
||||||
error: error.message,
|
|
||||||
...(isDev && {
|
|
||||||
details: error.responseBody,
|
|
||||||
stack: error.stack,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ status: error.statusCode || 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LoadAPIKeyError.isInstance(error)) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Authentication failed. Please check your API key.",
|
|
||||||
...(isDev && {
|
|
||||||
stack: error.stack,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for other errors with safety filter
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
|
||||||
const status = (error as any)?.statusCode || (error as any)?.status || 500
|
|
||||||
|
|
||||||
// Prevent leaking API keys, tokens, or other sensitive data
|
|
||||||
const lowerMessage = message.toLowerCase()
|
|
||||||
const safeMessage =
|
|
||||||
lowerMessage.includes("key") ||
|
|
||||||
lowerMessage.includes("token") ||
|
|
||||||
lowerMessage.includes("sig") ||
|
|
||||||
lowerMessage.includes("signature") ||
|
|
||||||
lowerMessage.includes("secret") ||
|
|
||||||
lowerMessage.includes("password") ||
|
|
||||||
lowerMessage.includes("credential")
|
|
||||||
? "Authentication failed. Please check your credentials."
|
|
||||||
: message
|
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: safeMessage,
|
...classified,
|
||||||
...(isDev && {
|
...(isDev && {
|
||||||
details: message,
|
details: APICallError.isInstance(error)
|
||||||
|
? error.responseBody
|
||||||
|
: undefined,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -761,11 +815,16 @@ function handleError(error: unknown): Response {
|
|||||||
|
|
||||||
// Wrap handler with error handling
|
// Wrap handler with error handling
|
||||||
async function safeHandler(req: Request): Promise<Response> {
|
async function safeHandler(req: Request): Promise<Response> {
|
||||||
|
let response: Response
|
||||||
try {
|
try {
|
||||||
return await handleChatRequest(req)
|
response = await handleChatRequest(req)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(error)
|
response = handleError(error)
|
||||||
}
|
}
|
||||||
|
// Early returns, cache hits and errors never reach streamText's callbacks,
|
||||||
|
// so their Langfuse trace has to be ended here
|
||||||
|
if (!modelStreamResponses.has(response)) endTrace()
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap with Langfuse observe (if configured)
|
// Wrap with Langfuse observe (if configured)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
|||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
format: z.enum(["drawio", "png", "svg"]),
|
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
||||||
sessionId: z.string().min(1).max(200).optional(),
|
sessionId: z.string().min(1).max(200).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+91
-57
@@ -1,62 +1,46 @@
|
|||||||
import { extract } from "@extractus/article-extractor"
|
import { extractFromHtml } from "@extractus/article-extractor"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||||
|
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
|
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||||
|
|
||||||
// SSRF protection - block private/internal addresses
|
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||||
function isPrivateUrl(urlString: string): boolean {
|
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||||
|
// would produce mojibake; the article-extractor library does the same detection
|
||||||
|
// when it fetches the page itself, which we no longer rely on.
|
||||||
|
function detectCharset(
|
||||||
|
contentType: string | null,
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
): string {
|
||||||
|
// 1. HTTP Content-Type header charset (most authoritative).
|
||||||
|
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||||
|
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||||
|
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||||
|
const metaCharset =
|
||||||
|
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||||
|
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||||
|
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||||
|
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString)
|
new TextDecoder(charset)
|
||||||
const hostname = url.hostname.toLowerCase()
|
return charset
|
||||||
|
|
||||||
// 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 {
|
} catch {
|
||||||
return true // Invalid URL - block it
|
return "utf-8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await req.json()
|
const { url } = await req.json()
|
||||||
|
|
||||||
@@ -77,28 +61,61 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection
|
// SSRF protection: parse-url has no use case for fetching internal
|
||||||
if (isPrivateUrl(url)) {
|
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||||
|
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||||
|
if (await isPrivateUrl(url)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Cannot access private/internal URLs" },
|
{ error: "Cannot access private/internal URLs" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Fetch the page ourselves so we control redirect handling. The
|
||||||
// Extract article content with timeout to avoid tying up server resources
|
// article-extractor library follows redirects internally and ignores a
|
||||||
|
// `redirect` option, which would let a public URL 302 to an internal
|
||||||
|
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||||
|
// redirect outright.
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}, EXTRACT_TIMEOUT_MS)
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
let article
|
let html: string
|
||||||
try {
|
try {
|
||||||
article = await extract(url, undefined, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: { "User-Agent": USER_AGENT },
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
redirect: "error",
|
||||||
},
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type")
|
||||||
|
if (contentType?.includes("application/pdf")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
|
||||||
|
if (!buffer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
||||||
|
},
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const charset = detectCharset(contentType, buffer)
|
||||||
|
html = new TextDecoder(charset).decode(buffer)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") {
|
if (err?.name === "AbortError") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -106,9 +123,26 @@ export async function POST(req: Request) {
|
|||||||
{ status: 504 },
|
{ status: 504 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
throw err
|
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||||
|
// "unexpected redirect") when redirect: "error" is set.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not fetch URL content" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
// Ends a download left unread (too large, PDF, error status);
|
||||||
|
// a body already read is not affected
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||||
|
// so map any parse error to the same 400 as the no-content case.
|
||||||
|
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||||
|
try {
|
||||||
|
article = await extractFromHtml(html, url)
|
||||||
|
} catch {
|
||||||
|
article = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!article || !article.content) {
|
if (!article || !article.content) {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { classifyLLMError } from "@/lib/llm-errors"
|
||||||
|
import {
|
||||||
|
canListModels,
|
||||||
|
listProviderModels,
|
||||||
|
ModelListError,
|
||||||
|
} from "@/lib/provider-models"
|
||||||
|
import {
|
||||||
|
allowPrivateUrls,
|
||||||
|
isPrivateUrl,
|
||||||
|
RedirectRefusedError,
|
||||||
|
redirectGuardedFetch,
|
||||||
|
} from "@/lib/ssrf-protection"
|
||||||
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
// Public lists need no key
|
||||||
|
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||||
|
"ollama",
|
||||||
|
"openrouter",
|
||||||
|
"aihubmix",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models a provider offers, for the "Fetch models" button in model
|
||||||
|
* settings. Answers { models: null } for providers that cannot list them,
|
||||||
|
* so the dialog keeps its suggested models.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Sends requests to a URL the client chose, so require the access code
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
||||||
|
provider: ProviderName
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
|
if (!canListModels(provider)) {
|
||||||
|
return NextResponse.json({ models: null })
|
||||||
|
}
|
||||||
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
|
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||||
|
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await listProviderModels(
|
||||||
|
provider,
|
||||||
|
{ apiKey, baseUrl },
|
||||||
|
(baseUrl && redirectGuardedFetch()) || fetch,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ models })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[provider-models] Listing failed:", error)
|
||||||
|
// Only our own explanations go back: the URL may be an internal
|
||||||
|
// address, whose answer or host names must not reach the caller.
|
||||||
|
// The Gateway SDK wraps them, keeping ours as the cause.
|
||||||
|
const isOwn = (e: unknown): e is Error =>
|
||||||
|
e instanceof ModelListError || e instanceof RedirectRefusedError
|
||||||
|
const cause = (error as { cause?: unknown })?.cause
|
||||||
|
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
||||||
|
const { code } = classifyLLMError(own ?? error)
|
||||||
|
return NextResponse.json({
|
||||||
|
code,
|
||||||
|
error: own?.message ?? "The model list request failed.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
||||||
|
|
||||||
|
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
||||||
|
// This ensures Docker users can set these values when starting containers
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const models = await loadFlattenedServerModels()
|
||||||
|
return NextResponse.json({
|
||||||
|
models,
|
||||||
|
hasConfig: models.length > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint for VLM-based diagram validation.
|
||||||
|
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Output, streamText } from "ai"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
|
import {
|
||||||
|
checkAndIncrementRequest,
|
||||||
|
isQuotaEnabled,
|
||||||
|
recordTokenUsage,
|
||||||
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
|
import {
|
||||||
|
type ValidationResult,
|
||||||
|
ValidationResultSchema,
|
||||||
|
} from "@/lib/validation-schema"
|
||||||
|
|
||||||
|
export const maxDuration = 30
|
||||||
|
|
||||||
|
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
|
||||||
|
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
interface ValidateDiagramRequest {
|
||||||
|
imageData: string // Base64 PNG data URL
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default valid result for disabled/error cases
|
||||||
|
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||||
|
valid: true,
|
||||||
|
issues: [],
|
||||||
|
suggestions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed result in the text format useObject reads */
|
||||||
|
function createStreamingResponse(result: ValidationResult): Response {
|
||||||
|
return new Response(JSON.stringify(result), {
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Uses the server's model credentials, so require the access code
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if VLM validation is enabled (default: true)
|
||||||
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
|
if (!enableValidation) {
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
|
const { imageData, sessionId } = body
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing imageData" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image data format
|
||||||
|
if (
|
||||||
|
!imageData.startsWith("data:image/png;base64,") &&
|
||||||
|
!imageData.startsWith("data:image/")
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid image data format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Image data too large" },
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It runs the server's vision model: with the quota on, the daily
|
||||||
|
// and per-minute token limits apply, and its tokens are counted. Not
|
||||||
|
// the request limit, which is for chats: the day's last chat still
|
||||||
|
// gets its check, and a check does not count as a chat.
|
||||||
|
const userId = getUserIdFromRequest(req)
|
||||||
|
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
|
||||||
|
if (countsQuota) {
|
||||||
|
const quotaCheck = await checkAndIncrementRequest(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
requests: 0,
|
||||||
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
|
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (!quotaCheck.allowed) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: quotaCheck.error,
|
||||||
|
type: quotaCheck.type,
|
||||||
|
used: quotaCheck.used,
|
||||||
|
limit: quotaCheck.limit,
|
||||||
|
},
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the validation model
|
||||||
|
let model
|
||||||
|
try {
|
||||||
|
model = getValidationModel()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[validate-diagram] Validation model not available:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
// Return valid if no vision model is configured
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
|
const timeout =
|
||||||
|
Math.max(
|
||||||
|
1000,
|
||||||
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
|
) || 10000
|
||||||
|
|
||||||
|
// Stream the VLM response for useObject consumption
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
output: Output.object({ schema: ValidationResultSchema }),
|
||||||
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
image: imageData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Please analyze this diagram for visual quality issues.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
abortSignal: AbortSignal.timeout(timeout),
|
||||||
|
onFinish: ({ output, totalUsage }) => {
|
||||||
|
if (countsQuota && totalUsage) {
|
||||||
|
recordTokenUsage(
|
||||||
|
userId,
|
||||||
|
(totalUsage.inputTokens || 0) +
|
||||||
|
(totalUsage.outputTokens || 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sessionId && output) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toTextStreamResponse()
|
||||||
|
} catch (error) {
|
||||||
|
// Log with session context if available
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
|
// On error, return valid to not block the user
|
||||||
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-325
@@ -1,78 +1,28 @@
|
|||||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
import { streamText, tool } from "ai"
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
|
||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|
||||||
import { generateText } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { z } from "zod"
|
||||||
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
|
import {
|
||||||
|
edgeOneEndpoint,
|
||||||
|
getAIModel,
|
||||||
|
globalBaseUrl,
|
||||||
|
usesServerCredentials,
|
||||||
|
usesServerEndpoint,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
|
import {
|
||||||
|
checkAndIncrementRequest,
|
||||||
|
isQuotaEnabled,
|
||||||
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { classifyLLMError } from "@/lib/llm-errors"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
/**
|
|
||||||
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
|
||||||
*/
|
|
||||||
function isPrivateUrl(urlString: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
const hostname = url.hostname.toLowerCase()
|
|
||||||
|
|
||||||
// Block localhost
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "::1"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block AWS/cloud metadata endpoints
|
|
||||||
if (
|
|
||||||
hostname === "169.254.169.254" ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private IPv4 ranges
|
|
||||||
const ipv4Match = hostname.match(
|
|
||||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
||||||
)
|
|
||||||
if (ipv4Match) {
|
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if (a === 10) return true
|
|
||||||
// 172.16.0.0/12
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if (a === 192 && b === 168) return true
|
|
||||||
// 169.254.0.0/16 (link-local)
|
|
||||||
if (a === 169 && b === 254) return true
|
|
||||||
// 127.0.0.0/8 (loopback)
|
|
||||||
if (a === 127) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block common internal hostnames
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".localhost")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
// Invalid URL - block it
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: ProviderName
|
||||||
apiKey: string
|
apiKey: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
modelId: string
|
modelId: string
|
||||||
@@ -80,19 +30,44 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string
|
||||||
|
// Vertex AI specific
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
|
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||||
|
serverBaseUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
// Drawing works through tool calls, so the test asks for one
|
||||||
|
const PING_TOOL = tool({
|
||||||
|
description: "Report that the connection works.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const NO_TOOL_CALL_WARNING =
|
||||||
|
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const crossSite = rejectCrossSite(req)
|
||||||
|
if (crossSite) return crossSite
|
||||||
|
// Lets the server send requests to arbitrary URLs, so require the access
|
||||||
|
// code, or the admin password (the admin panel's Test button)
|
||||||
|
const accessError = checkAccessCode(req)
|
||||||
|
if (accessError && checkAdminAuth(req)) return accessError
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
const body: ValidateRequest = await req.json()
|
||||||
const {
|
const {
|
||||||
provider,
|
provider,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
|
||||||
modelId,
|
modelId,
|
||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
|
awsSessionToken,
|
||||||
|
// Note: Express Mode only needs vertexApiKey
|
||||||
|
vertexApiKey,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -101,9 +76,26 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// EdgeOne is this site's own function, as in the chat; the admin
|
||||||
|
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||||
|
const baseUrl =
|
||||||
|
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||||
|
// The admin panel's Test of an entry without a URL sends the
|
||||||
|
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||||
|
// user chose, so no private-address or redirect rules
|
||||||
|
const serverUrl =
|
||||||
|
body.serverBaseUrl === true &&
|
||||||
|
!!baseUrl &&
|
||||||
|
baseUrl === globalBaseUrl(provider) &&
|
||||||
|
!checkAdminAuth(req)
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
if (
|
||||||
|
baseUrl &&
|
||||||
|
!serverUrl &&
|
||||||
|
!allowPrivateUrls() &&
|
||||||
|
(await isPrivateUrl(baseUrl))
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -121,278 +113,133 @@ 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" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The Test button checks the user's own provider. On the server's
|
||||||
let model: any
|
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||||
|
if (
|
||||||
switch (provider) {
|
usesServerCredentials(provider, {
|
||||||
case "openai": {
|
apiKey,
|
||||||
const openai = createOpenAI({
|
baseUrl,
|
||||||
apiKey,
|
awsAccessKeyId,
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
awsSecretAccessKey,
|
||||||
})
|
vertexApiKey,
|
||||||
model = openai.chat(modelId)
|
})
|
||||||
break
|
) {
|
||||||
}
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
case "anthropic": {
|
{ status: 400 },
|
||||||
const anthropic = createAnthropic({
|
)
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
|
||||||
})
|
|
||||||
model = anthropic(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "google": {
|
|
||||||
const google = createGoogleGenerativeAI({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = google(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
|
||||||
const azure = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl,
|
|
||||||
})
|
|
||||||
model = azure.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "bedrock": {
|
|
||||||
const bedrock = createAmazonBedrock({
|
|
||||||
accessKeyId: awsAccessKeyId,
|
|
||||||
secretAccessKey: awsSecretAccessKey,
|
|
||||||
region: awsRegion,
|
|
||||||
})
|
|
||||||
model = bedrock(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "openrouter": {
|
|
||||||
const openrouter = createOpenRouter({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = openrouter(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
|
||||||
if (baseUrl || apiKey) {
|
|
||||||
const ds = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = ds(modelId)
|
|
||||||
} else {
|
|
||||||
model = deepseek(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "siliconflow": {
|
|
||||||
const sf = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
|
||||||
})
|
|
||||||
model = sf.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ollama": {
|
|
||||||
const ollama = createOllama({
|
|
||||||
baseURL: baseUrl || "http://localhost:11434",
|
|
||||||
})
|
|
||||||
model = ollama(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "gateway": {
|
|
||||||
const gw = createGateway({
|
|
||||||
apiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = gw(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "edgeone": {
|
|
||||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
|
||||||
// Need to pass cookies for EdgeOne Pages authentication
|
|
||||||
const cookieHeader = req.headers.get("cookie") || ""
|
|
||||||
const edgeone = createOpenAI({
|
|
||||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
|
||||||
baseURL: baseUrl || "/api/edgeai",
|
|
||||||
headers: {
|
|
||||||
cookie: cookieHeader,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
model = edgeone.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "sglang": {
|
|
||||||
// SGLang is OpenAI-compatible
|
|
||||||
const sglang = createOpenAI({
|
|
||||||
apiKey: apiKey || "not-needed",
|
|
||||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
|
||||||
})
|
|
||||||
model = sglang.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "doubao": {
|
|
||||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
|
||||||
const doubaoBaseUrl =
|
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
|
||||||
const lowerModelId = modelId.toLowerCase()
|
|
||||||
if (
|
|
||||||
lowerModelId.includes("deepseek") ||
|
|
||||||
lowerModelId.includes("kimi")
|
|
||||||
) {
|
|
||||||
const doubao = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao(modelId)
|
|
||||||
} else {
|
|
||||||
const doubao = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "modelscope": {
|
|
||||||
const baseURL =
|
|
||||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseURL}/chat/completions`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelId,
|
|
||||||
messages: [
|
|
||||||
{ role: "user", content: "Say 'OK'" },
|
|
||||||
],
|
|
||||||
max_tokens: 20,
|
|
||||||
stream: true,
|
|
||||||
enable_thinking: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
throw new Error(
|
|
||||||
`ModelScope API error (${response.status}): ${errorText}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") || ""
|
|
||||||
const isValidStreamingResponse =
|
|
||||||
response.status === 200 &&
|
|
||||||
(contentType.includes("text/event-stream") ||
|
|
||||||
contentType.includes("application/json"))
|
|
||||||
|
|
||||||
if (!isValidStreamingResponse) {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected response format: ${contentType}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime
|
|
||||||
|
|
||||||
if (response.body) {
|
|
||||||
response.body.cancel().catch(() => {
|
|
||||||
/* Ignore cancellation errors */
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: true,
|
|
||||||
responseTime,
|
|
||||||
note: "ModelScope model validated (using streaming API)",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[validate-model] ModelScope validation failed:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a minimal test request
|
// On the deployment's own endpoints a Test runs a model as a chat
|
||||||
const startTime = Date.now()
|
// does, so with the quota on it counts as a chat request (an
|
||||||
await generateText({
|
// admin's Test of the server's URL does not)
|
||||||
model,
|
const userId = getUserIdFromRequest(req)
|
||||||
prompt: "Say 'OK'",
|
if (
|
||||||
maxOutputTokens: 20,
|
isQuotaEnabled() &&
|
||||||
|
!serverUrl &&
|
||||||
|
userId !== "anonymous" &&
|
||||||
|
(await usesServerEndpoint(
|
||||||
|
provider,
|
||||||
|
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||||
|
apiKey,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||||
|
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||||
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
|
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||||
|
})
|
||||||
|
if (!quotaCheck.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: quotaCheck.error },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same model the chat would use. A client base URL makes it
|
||||||
|
// refuse redirects to internal hosts.
|
||||||
|
const { model } = getAIModel({
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
trustedBaseUrl: serverUrl,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
// Temporary AWS credentials need it, as in the chat
|
||||||
|
awsSessionToken,
|
||||||
|
vertexApiKey,
|
||||||
|
// EdgeOne checks the Pages cookies and the access code
|
||||||
|
...(provider === "edgeone" && {
|
||||||
|
headers: {
|
||||||
|
cookie: req.headers.get("cookie") || "",
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Streaming, like the chat (some models only stream). Stop at the
|
||||||
|
// first tool call; a reasoning model that runs out of tokens first
|
||||||
|
// proves the connection but not tool support.
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
prompt: "Call the ping tool.",
|
||||||
|
tools: { ping: PING_TOOL },
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
maxRetries: 0,
|
||||||
|
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
let calledTool = false
|
||||||
|
let finishReason: string | undefined
|
||||||
|
for await (const part of result.fullStream) {
|
||||||
|
if (part.type === "error") throw part.error
|
||||||
|
// The timeout ends the stream with an abort part, not an error
|
||||||
|
if (part.type === "abort") {
|
||||||
|
const timeout = new Error(
|
||||||
|
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||||
|
)
|
||||||
|
timeout.name = "TimeoutError"
|
||||||
|
throw timeout
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
calledTool = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (part.type === "finish") finishReason = part.finishReason
|
||||||
|
}
|
||||||
const responseTime = Date.now() - startTime
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
responseTime,
|
responseTime,
|
||||||
|
...(!calledTool &&
|
||||||
|
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[validate-model] Error:", error)
|
console.error("[validate-model] Error:", error)
|
||||||
|
|
||||||
let errorMessage = "Validation failed"
|
const { code, message } = classifyLLMError(error)
|
||||||
if (error instanceof Error) {
|
|
||||||
// Extract meaningful error message
|
|
||||||
if (
|
|
||||||
error.message.includes("401") ||
|
|
||||||
error.message.includes("Unauthorized")
|
|
||||||
) {
|
|
||||||
errorMessage = "Invalid API key"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("404") ||
|
|
||||||
error.message.includes("not found")
|
|
||||||
) {
|
|
||||||
errorMessage = "Model not found"
|
|
||||||
} else if (
|
|
||||||
error.message.includes("429") ||
|
|
||||||
error.message.includes("rate limit")
|
|
||||||
) {
|
|
||||||
errorMessage = "Rate limited - try again later"
|
|
||||||
} else if (error.message.includes("ECONNREFUSED")) {
|
|
||||||
errorMessage = "Cannot connect to server"
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message.slice(0, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: errorMessage },
|
{ valid: false, code, error: message },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ status: 200 }, // Return 200 so client can read error message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
|
import { checkAccessCode } from "@/lib/access-code"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const accessCodes =
|
if (checkAccessCode(req)) {
|
||||||
process.env.ACCESS_CODE_LIST?.split(",")
|
|
||||||
.map((code) => code.trim())
|
|
||||||
.filter(Boolean) || []
|
|
||||||
|
|
||||||
// If no access codes configured, verification always passes
|
|
||||||
if (accessCodes.length === 0) {
|
|
||||||
return Response.json({
|
|
||||||
valid: true,
|
|
||||||
message: "No access code required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessCodeHeader = req.headers.get("x-access-code")
|
|
||||||
|
|
||||||
if (!accessCodeHeader) {
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ valid: false, message: "Access code is required" },
|
{ valid: false, message: "Invalid or missing access code" },
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessCodes.includes(accessCodeHeader)) {
|
|
||||||
return Response.json(
|
|
||||||
{ valid: false, message: "Invalid access code" },
|
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false
|
"ignoreUnknown": false,
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!public",
|
||||||
|
"!packages/mcp-server/src/preview",
|
||||||
|
"!lib/model-catalog.json"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Cloud } from "lucide-react"
|
import { Cloud } from "lucide-react"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -69,20 +70,62 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|||||||
export const ModelSelectorList = ({
|
export const ModelSelectorList = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorListProps) => (
|
}: ModelSelectorListProps) => {
|
||||||
<div className="relative">
|
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||||
<CommandList
|
const [showShadow, setShowShadow] = useState(false)
|
||||||
className={cn(
|
|
||||||
// Hide scrollbar on all platforms
|
useEffect(() => {
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
const listElement = listRef.current
|
||||||
className,
|
if (!listElement) return
|
||||||
)}
|
|
||||||
{...props}
|
const checkScroll = () => {
|
||||||
/>
|
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
// Show shadow if there is more content below
|
||||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
// Using a small threshold to handle fractional pixel rendering
|
||||||
</div>
|
setShowShadow(
|
||||||
)
|
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkScroll()
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
listElement.addEventListener("scroll", checkScroll)
|
||||||
|
window.addEventListener("resize", checkScroll)
|
||||||
|
|
||||||
|
// Observe content changes (e.g. async loading of items)
|
||||||
|
const observer = new MutationObserver(checkScroll)
|
||||||
|
observer.observe(listElement, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("scroll", checkScroll)
|
||||||
|
window.removeEventListener("resize", checkScroll)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
// Hide scrollbar on all platforms
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||||
|
showShadow ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
@@ -169,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>
|
||||||
|
)
|
||||||
|
|||||||
@@ -141,9 +141,6 @@ export default function ExamplePanel({
|
|||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
{dict.examples.mcpServer}
|
{dict.examples.mcpServer}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
|
||||||
{dict.examples.preview}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{dict.examples.mcpDescription}
|
{dict.examples.mcpDescription}
|
||||||
|
|||||||
+464
-353
@@ -1,17 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Link,
|
Link,
|
||||||
Loader2,
|
|
||||||
Send,
|
Send,
|
||||||
|
Square,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
forwardRef,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
@@ -27,13 +38,25 @@ import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
|||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
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 { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||||
|
import { isRealDiagram } from "@/lib/utils"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
const MAX_FILES = 5
|
const MAX_FILES = 5
|
||||||
|
// Image formats every supported model provider accepts (SVG is read as text)
|
||||||
|
const SUPPORTED_IMAGE_TYPES = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
]
|
||||||
|
|
||||||
function isValidFileType(file: File): boolean {
|
function isValidFileType(file: File): boolean {
|
||||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
return (
|
||||||
|
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||||
|
isPdfFile(file) ||
|
||||||
|
isTextFile(file)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -137,11 +160,16 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatInputRef {
|
||||||
|
focus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string
|
input: string
|
||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
|
onStop?: () => void
|
||||||
files?: File[]
|
files?: File[]
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void
|
||||||
pdfData?: Map<
|
pdfData?: Map<
|
||||||
@@ -149,7 +177,7 @@ interface ChatInputProps {
|
|||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<string, UrlData>
|
urlData?: Map<string, UrlData>
|
||||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -157,122 +185,230 @@ interface ChatInputProps {
|
|||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
|
showUnvalidatedModels?: boolean
|
||||||
|
// Focus control props
|
||||||
|
shouldFocus?: boolean
|
||||||
|
onFocused?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||||
input,
|
function ChatInput(
|
||||||
status,
|
{
|
||||||
onSubmit,
|
input,
|
||||||
onChange,
|
status,
|
||||||
files = [],
|
onSubmit,
|
||||||
onFileChange = () => {},
|
onChange,
|
||||||
pdfData = new Map(),
|
onStop,
|
||||||
urlData,
|
files = [],
|
||||||
onUrlChange,
|
onFileChange = () => {},
|
||||||
sessionId,
|
pdfData = new Map(),
|
||||||
error = null,
|
urlData,
|
||||||
models = [],
|
onUrlChange,
|
||||||
selectedModelId,
|
sessionId,
|
||||||
onModelSelect = () => {},
|
error = null,
|
||||||
showUnvalidatedModels = false,
|
models = [],
|
||||||
onConfigureModels = () => {},
|
selectedModelId,
|
||||||
}: ChatInputProps) {
|
onModelSelect = () => {},
|
||||||
const dict = useDictionary()
|
onConfigureModels,
|
||||||
const {
|
showUnvalidatedModels = false,
|
||||||
diagramHistory,
|
shouldFocus = false,
|
||||||
saveDiagramToFile,
|
onFocused,
|
||||||
showSaveDialog,
|
},
|
||||||
setShowSaveDialog,
|
ref,
|
||||||
} = useDiagram()
|
) {
|
||||||
|
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 [showHistory, setShowHistory] = useState(false)
|
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
|
||||||
const isDisabled =
|
|
||||||
(status === "streaming" || status === "submitted") && !error
|
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
// 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`
|
},
|
||||||
}
|
}))
|
||||||
}, [])
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}, [input, adjustTextareaHeight])
|
|
||||||
|
|
||||||
// Load send shortcut preference from localStorage and listen for changes
|
// Focus the textarea when shouldFocus becomes true
|
||||||
useEffect(() => {
|
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
useEffect(() => {
|
||||||
if (stored) setSendShortcut(stored)
|
if (shouldFocus) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
onFocused?.()
|
||||||
|
}, 150)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [shouldFocus, onFocused])
|
||||||
|
|
||||||
const handleChange = (e: CustomEvent<string>) =>
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
setSendShortcut(e.detail)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
window.addEventListener(
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||||
"sendShortcutChange",
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
handleChange as EventListener,
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
)
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
return () =>
|
const isDisabled =
|
||||||
window.removeEventListener(
|
(status === "streaming" || status === "submitted") && !error
|
||||||
|
// Block sending until attached files and URLs have their text, otherwise
|
||||||
|
// their content would be silently dropped
|
||||||
|
const isExtractingAttachments =
|
||||||
|
files.some((file) => pdfData.get(file)?.isExtracting) ||
|
||||||
|
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
|
||||||
|
|
||||||
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = "auto"
|
||||||
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
|
// Load send shortcut preference from localStorage and listen for changes
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||||
|
if (stored) setSendShortcut(stored)
|
||||||
|
|
||||||
|
const handleChange = (e: CustomEvent<string>) =>
|
||||||
|
setSendShortcut(e.detail)
|
||||||
|
window.addEventListener(
|
||||||
"sendShortcutChange",
|
"sendShortcutChange",
|
||||||
handleChange as EventListener,
|
handleChange as EventListener,
|
||||||
)
|
)
|
||||||
}, [])
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"sendShortcutChange",
|
||||||
|
handleChange as EventListener,
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
onChange(e)
|
onChange(e)
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const shouldSend =
|
// Enter that confirms an IME candidate must not send the message
|
||||||
sendShortcut === "enter"
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||||
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
|
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
|
||||||
|
|
||||||
if (shouldSend) {
|
const shouldSend =
|
||||||
e.preventDefault()
|
sendShortcut === "enter"
|
||||||
const form = e.currentTarget.closest("form")
|
? e.key === "Enter" &&
|
||||||
if (form && input.trim() && !isDisabled) {
|
!e.shiftKey &&
|
||||||
form.requestSubmit()
|
!e.ctrlKey &&
|
||||||
|
!e.metaKey
|
||||||
|
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||||
|
|
||||||
|
if (shouldSend) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget.closest("form")
|
||||||
|
if (
|
||||||
|
form &&
|
||||||
|
input.trim() &&
|
||||||
|
!isDisabled &&
|
||||||
|
!isExtractingAttachments
|
||||||
|
) {
|
||||||
|
form.requestSubmit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
if (isDisabled) return
|
if (isDisabled) return
|
||||||
|
|
||||||
const items = e.clipboardData.items
|
const items = e.clipboardData.items
|
||||||
const imageItems = Array.from(items).filter((item) =>
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
item.type.startsWith("image/"),
|
item.type.startsWith("image/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = (
|
const imageFiles = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
imageItems.map(async (item, index) => {
|
imageItems.map(async (item, index) => {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
return new File(
|
return new File(
|
||||||
[file],
|
[file],
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
{ type: file.type },
|
{ type: file.type },
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
).filter((f): f is File => f !== null)
|
||||||
|
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
imageFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
)
|
)
|
||||||
).filter((f): f is File => f !== null)
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newFiles = Array.from(e.target.files || [])
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
imageFiles,
|
newFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
|
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (isDisabled) return
|
||||||
|
|
||||||
|
// Let validateFiles show a toast for unsupported types
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
Array.from(e.dataTransfer.files),
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -281,278 +417,253 @@ export function ChatInput({
|
|||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlExtract = async (url: string) => {
|
||||||
const newFiles = Array.from(e.target.files || [])
|
if (!onUrlChange) return
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
newFiles,
|
setIsExtractingUrl(true)
|
||||||
files.length,
|
|
||||||
dict,
|
// Use functional updates so a removal or send made while extracting
|
||||||
)
|
// is not overwritten when the request finishes
|
||||||
showValidationErrors(errors, dict)
|
try {
|
||||||
if (validFiles.length > 0) {
|
onUrlChange((prev) =>
|
||||||
onFileChange([...files, ...validFiles])
|
new Map(prev).set(url, {
|
||||||
|
url,
|
||||||
|
title: url,
|
||||||
|
content: "",
|
||||||
|
charCount: 0,
|
||||||
|
isExtracting: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
|
// Skip if the URL was removed while extracting
|
||||||
|
onUrlChange((prev) =>
|
||||||
|
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
||||||
|
)
|
||||||
|
|
||||||
|
setShowUrlDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
// Remove the URL from the data map on error
|
||||||
|
onUrlChange((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(url)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showErrorToast(
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to extract URL content"}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsExtractingUrl(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
return (
|
||||||
fileInputRef.current.value = ""
|
<form
|
||||||
}
|
id="chat-form"
|
||||||
}
|
onSubmit={onSubmit}
|
||||||
|
className={`w-full transition-all duration-200 ${
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
isDragging
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||||
if (fileInputRef.current) {
|
: ""
|
||||||
fileInputRef.current.value = ""
|
}`}
|
||||||
}
|
onDragOver={handleDragOver}
|
||||||
}
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
const triggerFileInput = () => {
|
>
|
||||||
fileInputRef.current?.click()
|
{/* File & URL previews */}
|
||||||
}
|
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||||
|
<div className="mb-3">
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
<FilePreviewList
|
||||||
e.preventDefault()
|
files={files}
|
||||||
e.stopPropagation()
|
onRemoveFile={handleRemoveFile}
|
||||||
setIsDragging(true)
|
pdfData={pdfData}
|
||||||
}
|
urlData={urlData}
|
||||||
|
onRemoveUrl={
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
onUrlChange
|
||||||
e.preventDefault()
|
? (url) =>
|
||||||
e.stopPropagation()
|
onUrlChange((prev) => {
|
||||||
setIsDragging(false)
|
const next = new Map(prev)
|
||||||
}
|
next.delete(url)
|
||||||
|
return next
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
})
|
||||||
e.preventDefault()
|
: undefined
|
||||||
e.stopPropagation()
|
}
|
||||||
setIsDragging(false)
|
/>
|
||||||
|
</div>
|
||||||
if (isDisabled) return
|
)}
|
||||||
|
<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">
|
||||||
const droppedFiles = e.dataTransfer.files
|
<Textarea
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
ref={textareaRef}
|
||||||
isValidFileType(file),
|
value={input}
|
||||||
)
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
const { validFiles, errors } = validateFiles(
|
onPaste={handlePaste}
|
||||||
supportedFiles,
|
placeholder={dict.chat.placeholder}
|
||||||
files.length,
|
disabled={isDisabled}
|
||||||
dict,
|
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"
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUrlExtract = async (url: string) => {
|
|
||||||
if (!onUrlChange) return
|
|
||||||
|
|
||||||
setIsExtractingUrl(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
existing.set(url, {
|
|
||||||
url,
|
|
||||||
title: url,
|
|
||||||
content: "",
|
|
||||||
charCount: 0,
|
|
||||||
isExtracting: true,
|
|
||||||
})
|
|
||||||
onUrlChange(existing)
|
|
||||||
|
|
||||||
const data = await extractUrlContent(url)
|
|
||||||
|
|
||||||
const newUrlData = new Map(existing)
|
|
||||||
newUrlData.set(url, data)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
|
|
||||||
setShowUrlDialog(false)
|
|
||||||
} catch (error) {
|
|
||||||
// Remove the URL from the data map on error
|
|
||||||
const newUrlData = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
newUrlData.delete(url)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
showErrorToast(
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to extract URL content"}
|
|
||||||
</span>,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsExtractingUrl(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className={`w-full transition-all duration-200 ${
|
|
||||||
isDragging
|
|
||||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
{/* File & URL previews */}
|
|
||||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<FilePreviewList
|
|
||||||
files={files}
|
|
||||||
onRemoveFile={handleRemoveFile}
|
|
||||||
pdfData={pdfData}
|
|
||||||
urlData={urlData}
|
|
||||||
onRemoveUrl={
|
|
||||||
onUrlChange
|
|
||||||
? (url) => {
|
|
||||||
const next = new Map(urlData)
|
|
||||||
next.delete(url)
|
|
||||||
onUrlChange(next)
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
placeholder={dict.chat.placeholder}
|
|
||||||
disabled={isDisabled}
|
|
||||||
aria-label="Chat input"
|
|
||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
<div className="flex items-center 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
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowHistory(true)}
|
|
||||||
disabled={isDisabled || diagramHistory.length === 0}
|
|
||||||
tooltipContent={dict.chat.diagramHistory}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<History className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSaveDialog(true)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={triggerFileInput}
|
|
||||||
disabled={isDisabled}
|
|
||||||
tooltipContent={dict.chat.uploadFile}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
{onUrlChange && (
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUrlDialog(true)}
|
onClick={() => setShowHistory(true)}
|
||||||
disabled={isDisabled}
|
disabled={
|
||||||
tooltipContent={dict.chat.ExtractURL}
|
isDisabled || diagramHistory.length === 0
|
||||||
|
}
|
||||||
|
tooltipContent={dict.chat.diagramHistory}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<Link className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
<ButtonWithTooltip
|
||||||
type="file"
|
type="button"
|
||||||
ref={fileInputRef}
|
variant="ghost"
|
||||||
className="hidden"
|
size="sm"
|
||||||
onChange={handleFileChange}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
disabled={
|
||||||
multiple
|
isDisabled || !isRealDiagram(chartXML)
|
||||||
|
}
|
||||||
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.uploadFile}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
{onUrlChange && (
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUrlDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.ExtractURL}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||||
|
multiple
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
onConfigure={onConfigureModels}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<ModelSelector
|
{(status === "streaming" || status === "submitted") &&
|
||||||
models={models}
|
onStop ? (
|
||||||
selectedModelId={selectedModelId}
|
<Button
|
||||||
onSelect={onModelSelect}
|
type="button"
|
||||||
onConfigure={onConfigureModels}
|
onClick={onStop}
|
||||||
disabled={isDisabled}
|
size="sm"
|
||||||
showUnvalidatedModels={showUnvalidatedModels}
|
variant="destructive"
|
||||||
/>
|
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
aria-label={dict.chat.stopGeneration}
|
||||||
<Button
|
>
|
||||||
type="submit"
|
<Square className="h-4 w-4" />
|
||||||
disabled={isDisabled || !input.trim()}
|
</Button>
|
||||||
size="sm"
|
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
|
||||||
aria-label={
|
|
||||||
isDisabled ? dict.chat.sending : dict.chat.send
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isDisabled ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isDisabled ||
|
||||||
|
isExtractingAttachments ||
|
||||||
|
!input.trim()
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
|
aria-label={dict.chat.send}
|
||||||
|
>
|
||||||
<Send className="h-4 w-4 mr-1.5" />
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
{dict.chat.send}
|
{dict.chat.send}
|
||||||
</>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<HistoryDialog
|
||||||
<HistoryDialog
|
showHistory={showHistory}
|
||||||
showHistory={showHistory}
|
onToggleHistory={setShowHistory}
|
||||||
onToggleHistory={setShowHistory}
|
|
||||||
/>
|
|
||||||
<SaveDialog
|
|
||||||
open={showSaveDialog}
|
|
||||||
onOpenChange={setShowSaveDialog}
|
|
||||||
onSave={(filename, format) =>
|
|
||||||
saveDiagramToFile(
|
|
||||||
filename,
|
|
||||||
format,
|
|
||||||
sessionId,
|
|
||||||
dict.save.savedSuccessfully,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
defaultFilename={`diagram-${new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}`}
|
|
||||||
/>
|
|
||||||
{onUrlChange && (
|
|
||||||
<UrlInputDialog
|
|
||||||
open={showUrlDialog}
|
|
||||||
onOpenChange={setShowUrlDialog}
|
|
||||||
onSubmit={handleUrlExtract}
|
|
||||||
isExtracting={isExtractingUrl}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<SaveDialog
|
||||||
</form>
|
open={showSaveDialog}
|
||||||
)
|
onOpenChange={setShowSaveDialog}
|
||||||
}
|
onSave={(filename, format) =>
|
||||||
|
saveDiagramToFile(
|
||||||
|
filename,
|
||||||
|
format,
|
||||||
|
sessionId,
|
||||||
|
dict.save.savedSuccessfully,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||||
|
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
||||||
|
/>
|
||||||
|
{onUrlChange && (
|
||||||
|
<UrlInputDialog
|
||||||
|
open={showUrlDialog}
|
||||||
|
onOpenChange={setShowUrlDialog}
|
||||||
|
onSubmit={handleUrlExtract}
|
||||||
|
isExtracting={isExtractingUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={showSaveAsTemplate}
|
||||||
|
onOpenChange={setShowSaveAsTemplate}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowSaveAsTemplate(false)
|
||||||
|
// Let the template list in the lobby reload
|
||||||
|
window.dispatchEvent(new Event("templatesChanged"))
|
||||||
|
}}
|
||||||
|
initialPrompt={input.trim()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
+359
-279
@@ -3,19 +3,20 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
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"
|
||||||
@@ -25,19 +26,24 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
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"
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
|
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -57,20 +63,20 @@ function getCompleteOperations(
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -81,28 +87,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 })
|
||||||
}
|
}
|
||||||
@@ -118,12 +130,14 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
|
||||||
|
export const APPENDED_FILE_SECTIONS_PATTERN =
|
||||||
|
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
|
|
||||||
// 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
|
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
|
||||||
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
|
||||||
return fullText.replace(filePattern, "").trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
@@ -135,6 +149,8 @@ interface SessionMetadata {
|
|||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
|
// Shown on an error that a model setting can fix (bad key, unknown model)
|
||||||
|
onOpenModelConfig?: () => void
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||||
@@ -148,10 +164,17 @@ interface ChatMessageDisplayProps {
|
|||||||
onSelectSession?: (id: string) => void
|
onSelectSession?: (id: string) => void
|
||||||
onDeleteSession?: (id: string) => void
|
onDeleteSession?: (id: string) => void
|
||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
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({
|
||||||
messages,
|
messages,
|
||||||
|
onOpenModelConfig,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
@@ -165,9 +188,30 @@ export function ChatMessageDisplay({
|
|||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
validationStates = {},
|
||||||
|
onImproveWithSuggestions,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
// The thinking header in the page language
|
||||||
|
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
|
if (isStreaming || duration === 0) {
|
||||||
|
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
|
||||||
|
}
|
||||||
|
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
{duration === 1
|
||||||
|
? dict.reasoning.thoughtForOne
|
||||||
|
: dict.reasoning.thoughtFor.replace(
|
||||||
|
"{duration}",
|
||||||
|
String(duration),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
@@ -187,20 +231,6 @@ export function ChatMessageDisplay({
|
|||||||
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
||||||
}
|
}
|
||||||
}, [messages.length, processedToolCalls])
|
}, [messages.length, processedToolCalls])
|
||||||
// Debounce streaming diagram updates - store pending XML and timeout
|
|
||||||
const pendingXmlRef = useRef<string | null>(null)
|
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
|
|
||||||
// Refs for edit_diagram streaming
|
|
||||||
const pendingEditRef = useRef<{
|
|
||||||
operations: DiagramOperation[]
|
|
||||||
toolCallId: string
|
|
||||||
} | null>(null)
|
|
||||||
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
@@ -224,6 +254,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,
|
||||||
@@ -318,73 +352,30 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streaming preview of display_diagram: draw the complete cells written
|
||||||
|
// so far. The tool handler validates and loads the final diagram.
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string) => {
|
||||||
let currentXml = xml || ""
|
const completeCells = extractCompleteMxCells(xml || "")
|
||||||
|
if (!completeCells) return
|
||||||
|
const convertedXml = convertToLegalXml(completeCells)
|
||||||
|
if (convertedXml === previousXML.current) return
|
||||||
|
|
||||||
// During streaming (showToast=false), extract only complete mxCell elements
|
// Skip this update while the cells written so far don't parse
|
||||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
const testDoc = new DOMParser().parseFromString(
|
||||||
if (!showToast) {
|
`<root>${convertedXml}</root>`,
|
||||||
const completeCells = extractCompleteMxCells(currentXml)
|
"text/xml",
|
||||||
if (!completeCells) {
|
)
|
||||||
return
|
if (testDoc.querySelector("parsererror")) return
|
||||||
}
|
|
||||||
currentXml = completeCells
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertedXml = convertToLegalXml(currentXml)
|
try {
|
||||||
if (convertedXml !== previousXML.current) {
|
// An empty canvas gets a default mxfile to put the cells in
|
||||||
// Parse and validate XML BEFORE calling replaceNodes
|
const baseXML = chartXML || BLANK_MXFILE
|
||||||
const parser = new DOMParser()
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
// Wrap in root element for parsing multiple mxCell elements
|
previousXML.current = convertedXml
|
||||||
const testDoc = parser.parseFromString(
|
onDisplayChart(replacedXML, true)
|
||||||
`<root>${convertedXml}</root>`,
|
} catch (error) {
|
||||||
"text/xml",
|
console.error("Error processing XML:", error)
|
||||||
)
|
|
||||||
const parseError = testDoc.querySelector("parsererror")
|
|
||||||
|
|
||||||
if (parseError) {
|
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
|
||||||
if (showToast) {
|
|
||||||
toast.error(dict.errors.malformedXml)
|
|
||||||
}
|
|
||||||
return // Skip this update
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
|
||||||
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
|
||||||
const baseXML =
|
|
||||||
chartXML ||
|
|
||||||
`<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)
|
|
||||||
|
|
||||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
|
||||||
// The quick DOM parse check above catches malformed XML
|
|
||||||
// Full validation runs on final output (showToast=true)
|
|
||||||
if (!showToast) {
|
|
||||||
previousXML.current = convertedXml
|
|
||||||
onDisplayChart(replacedXML, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final output: run full validation and auto-fix
|
|
||||||
const validation = validateAndFixXml(replacedXML)
|
|
||||||
if (validation.valid) {
|
|
||||||
previousXML.current = convertedXml
|
|
||||||
// Use fixed XML if available, otherwise use original
|
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
|
||||||
onDisplayChart(xmlToLoad, true)
|
|
||||||
} else {
|
|
||||||
toast.error(dict.errors.validationFailed)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing XML:", error)
|
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
|
||||||
if (showToast) {
|
|
||||||
toast.error(dict.errors.failedToProcess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
@@ -392,6 +383,7 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
// Track previous message count to detect bulk loads vs streaming
|
// Track previous message count to detect bulk loads vs streaming
|
||||||
const prevMessageCountRef = useRef(0)
|
const prevMessageCountRef = useRef(0)
|
||||||
|
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && messages.length > 0) {
|
if (messagesEndRef.current && messages.length > 0) {
|
||||||
@@ -405,8 +397,17 @@ export function ChatMessageDisplay({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single message added - smooth scroll
|
// Throttle scroll during streaming to avoid layout thrashing
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
// Leading + trailing: scroll immediately, then once more after cooldown
|
||||||
|
if (!scrollThrottleRef.current) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
|
scrollThrottleRef.current = setTimeout(() => {
|
||||||
|
scrollThrottleRef.current = null
|
||||||
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
@@ -421,91 +422,117 @@ export function ChatMessageDisplay({
|
|||||||
// Previous messages are already processed and won't change
|
// Previous messages are already processed and won't change
|
||||||
const messagesToProcess =
|
const messagesToProcess =
|
||||||
messages.length > 0 ? [messages[messages.length - 1]] : []
|
messages.length > 0 ? [messages[messages.length - 1]] : []
|
||||||
|
// The diagram without streamed previews, as loaded last: the tool
|
||||||
|
// handler's result of an earlier edit is there before the chartXML
|
||||||
|
// state catches up. Undoing a failed edit's preview below changes it
|
||||||
|
// too, and an edit streaming right after must start from the undone
|
||||||
|
// diagram.
|
||||||
|
let baseXml = chartXMLRef.current
|
||||||
|
|
||||||
messagesToProcess.forEach((message) => {
|
messagesToProcess.forEach((message) => {
|
||||||
|
// Messages restored from a saved session were applied before it was
|
||||||
|
// saved; the saved diagram is authoritative, so don't replay them
|
||||||
|
const isRestoredMessage =
|
||||||
|
loadedMessageIdsRef?.current.has(message.id) ?? false
|
||||||
|
|
||||||
if (message.parts) {
|
if (message.parts) {
|
||||||
message.parts.forEach((part) => {
|
message.parts.forEach((part) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
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 (isRestoredMessage) return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
part.type === "tool-display_diagram" &&
|
part.type !== "tool-display_diagram" &&
|
||||||
input?.xml
|
part.type !== "tool-edit_diagram"
|
||||||
) {
|
) {
|
||||||
const xml = input.xml as string
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed or stopped: if the original XML is still
|
||||||
|
// stored, the tool handler never ran (invalid JSON,
|
||||||
|
// or the user pressed stop), so undo the streamed
|
||||||
|
// preview here. Invalid JSON leaves no input, so
|
||||||
|
// check this first.
|
||||||
|
if (state === "output-error") {
|
||||||
|
const originalXml =
|
||||||
|
editDiagramOriginalXmlRef.current.get(
|
||||||
|
toolCallId,
|
||||||
|
)
|
||||||
|
if (originalXml) {
|
||||||
|
editDiagramOriginalXmlRef.current.delete(
|
||||||
|
toolCallId,
|
||||||
|
)
|
||||||
|
onDisplayChart(originalXml, true)
|
||||||
|
baseXml = originalXml
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input complete, or the tool handler, a stop or an
|
||||||
|
// error took the call already: the tool handler loads
|
||||||
|
// the checked diagram (with the original XML). The
|
||||||
|
// messages update at most every 150 ms (useChat
|
||||||
|
// throttle in chat-panel), so they can still show the
|
||||||
|
// call streaming after that.
|
||||||
|
if (
|
||||||
|
state !== "input-streaming" ||
|
||||||
|
processedToolCalls.current.has(toolCallId)
|
||||||
|
) {
|
||||||
|
processedToolCalls.current.add(toolCallId)
|
||||||
|
lastProcessedXmlRef.current.delete(toolCallId)
|
||||||
|
lastProcessedXmlRef.current.delete(
|
||||||
|
`${toolCallId}-opCount`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "tool-display_diagram") {
|
||||||
|
const xml = input?.xml as string | undefined
|
||||||
// Skip if XML hasn't changed since last processing
|
// Skip if XML hasn't changed since last processing
|
||||||
const lastXml =
|
|
||||||
lastProcessedXmlRef.current.get(toolCallId)
|
|
||||||
if (lastXml === xml) {
|
|
||||||
return // Skip redundant processing
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state === "input-streaming" ||
|
!xml ||
|
||||||
state === "input-available"
|
lastProcessedXmlRef.current.get(toolCallId) ===
|
||||||
|
xml
|
||||||
) {
|
) {
|
||||||
// Debounce streaming updates - queue the XML and process after delay
|
return
|
||||||
pendingXmlRef.current = xml
|
|
||||||
|
|
||||||
if (!debounceTimeoutRef.current) {
|
|
||||||
// No pending timeout - set one up
|
|
||||||
debounceTimeoutRef.current = setTimeout(
|
|
||||||
() => {
|
|
||||||
const pendingXml =
|
|
||||||
pendingXmlRef.current
|
|
||||||
debounceTimeoutRef.current = null
|
|
||||||
pendingXmlRef.current = null
|
|
||||||
if (pendingXml) {
|
|
||||||
handleDisplayChart(
|
|
||||||
pendingXml,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
lastProcessedXmlRef.current.set(
|
|
||||||
toolCallId,
|
|
||||||
pendingXml,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
STREAMING_DEBOUNCE_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
state === "output-available" &&
|
|
||||||
!processedToolCalls.current.has(toolCallId)
|
|
||||||
) {
|
|
||||||
// Final output - process immediately (clear any pending debounce)
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
|
||||||
debounceTimeoutRef.current = null
|
|
||||||
pendingXmlRef.current = null
|
|
||||||
}
|
|
||||||
// Show toast only if final XML is malformed
|
|
||||||
handleDisplayChart(xml, true)
|
|
||||||
processedToolCalls.current.add(toolCallId)
|
|
||||||
// Clean up the ref entry - tool is complete, no longer needed
|
|
||||||
lastProcessedXmlRef.current.delete(toolCallId)
|
|
||||||
}
|
}
|
||||||
|
// Keep the diagram from before the preview, to
|
||||||
|
// undo it on a stop or an error
|
||||||
|
if (
|
||||||
|
!editDiagramOriginalXmlRef.current.has(
|
||||||
|
toolCallId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
editDiagramOriginalXmlRef.current.set(
|
||||||
|
toolCallId,
|
||||||
|
baseXml || BLANK_MXFILE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleDisplayChart(xml)
|
||||||
|
lastProcessedXmlRef.current.set(toolCallId, xml)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle edit_diagram streaming - apply operations incrementally for preview
|
// Handle edit_diagram streaming - apply operations incrementally for preview
|
||||||
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
||||||
if (
|
if (part.type === "tool-edit_diagram") {
|
||||||
part.type === "tool-edit_diagram" &&
|
if (!input?.operations) return
|
||||||
input?.operations
|
|
||||||
) {
|
|
||||||
const completeOps = getCompleteOperations(
|
const completeOps = getCompleteOperations(
|
||||||
input.operations as DiagramOperation[],
|
input.operations as DiagramOperation[],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (completeOps.length === 0) return
|
if (completeOps.length === 0) return
|
||||||
|
|
||||||
// Capture original XML when streaming starts (store in shared ref)
|
// Capture original XML when streaming starts (store in shared ref)
|
||||||
@@ -514,7 +541,7 @@ export function ChatMessageDisplay({
|
|||||||
toolCallId,
|
toolCallId,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (!chartXML) {
|
if (!baseXml) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[edit_diagram streaming] No chart XML available",
|
"[edit_diagram streaming] No chart XML available",
|
||||||
)
|
)
|
||||||
@@ -522,10 +549,9 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
editDiagramOriginalXmlRef.current.set(
|
editDiagramOriginalXmlRef.current.set(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
chartXML,
|
baseXml,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalXml =
|
const originalXml =
|
||||||
editDiagramOriginalXmlRef.current.get(
|
editDiagramOriginalXmlRef.current.get(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -533,95 +559,37 @@ export function ChatMessageDisplay({
|
|||||||
if (!originalXml) return
|
if (!originalXml) return
|
||||||
|
|
||||||
// Skip if no change from last processed state
|
// Skip if no change from last processed state
|
||||||
const lastCount = lastProcessedXmlRef.current.get(
|
const countKey = `${toolCallId}-opCount`
|
||||||
toolCallId + "-opCount",
|
const opCount = String(completeOps.length)
|
||||||
)
|
|
||||||
if (lastCount === String(completeOps.length)) return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state === "input-streaming" ||
|
lastProcessedXmlRef.current.get(countKey) ===
|
||||||
state === "input-available"
|
opCount
|
||||||
) {
|
) {
|
||||||
// Queue the operations for debounced processing
|
return
|
||||||
pendingEditRef.current = {
|
}
|
||||||
operations: completeOps,
|
try {
|
||||||
toolCallId,
|
const { result } = applyDiagramOperations(
|
||||||
}
|
originalXml,
|
||||||
|
completeOps,
|
||||||
if (!editDebounceTimeoutRef.current) {
|
)
|
||||||
editDebounceTimeoutRef.current = setTimeout(
|
// Load the full document so other pages stay intact
|
||||||
() => {
|
onDisplayChart(result, true)
|
||||||
const pending =
|
lastProcessedXmlRef.current.set(
|
||||||
pendingEditRef.current
|
countKey,
|
||||||
editDebounceTimeoutRef.current =
|
opCount,
|
||||||
null
|
)
|
||||||
pendingEditRef.current = null
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
if (pending) {
|
"[edit_diagram streaming] Operation failed:",
|
||||||
const origXml =
|
e instanceof Error ? e.message : e,
|
||||||
editDiagramOriginalXmlRef.current.get(
|
|
||||||
pending.toolCallId,
|
|
||||||
)
|
|
||||||
if (!origXml) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
result: editedXml,
|
|
||||||
} = applyDiagramOperations(
|
|
||||||
origXml,
|
|
||||||
pending.operations,
|
|
||||||
)
|
|
||||||
handleDisplayChart(
|
|
||||||
editedXml,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
lastProcessedXmlRef.current.set(
|
|
||||||
pending.toolCallId +
|
|
||||||
"-opCount",
|
|
||||||
String(
|
|
||||||
pending.operations
|
|
||||||
.length,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
`[edit_diagram streaming] Operation failed:`,
|
|
||||||
e instanceof Error
|
|
||||||
? e.message
|
|
||||||
: e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
STREAMING_DEBOUNCE_MS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
state === "output-available" &&
|
|
||||||
!processedToolCalls.current.has(toolCallId)
|
|
||||||
) {
|
|
||||||
// Final state - cleanup streaming refs (tool handler does final application)
|
|
||||||
if (editDebounceTimeoutRef.current) {
|
|
||||||
clearTimeout(editDebounceTimeoutRef.current)
|
|
||||||
editDebounceTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
lastProcessedXmlRef.current.delete(
|
|
||||||
toolCallId + "-opCount",
|
|
||||||
)
|
)
|
||||||
processedToolCalls.current.add(toolCallId)
|
|
||||||
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}, [messages, handleDisplayChart, chartXMLRef])
|
||||||
// NOTE: Don't cleanup debounce timeouts here!
|
|
||||||
// The cleanup runs on every re-render (when messages changes),
|
|
||||||
// which would cancel the timeout before it fires.
|
|
||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
@@ -633,6 +601,8 @@ export function ChatMessageDisplay({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
dict={dict}
|
dict={dict}
|
||||||
/>
|
/>
|
||||||
) : messages.length === 0 ? null : (
|
) : messages.length === 0 ? null : (
|
||||||
@@ -730,8 +700,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" &&
|
||||||
@@ -770,7 +774,11 @@ export function ChatMessageDisplay({
|
|||||||
!isRestoredMessage
|
!isRestoredMessage
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger
|
||||||
|
getThinkingMessage={
|
||||||
|
thinkingMessage
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
{
|
{
|
||||||
reasoningPart.text
|
reasoningPart.text
|
||||||
@@ -876,8 +884,12 @@ export function ChatMessageDisplay({
|
|||||||
part.type?.startsWith(
|
part.type?.startsWith(
|
||||||
"tool-",
|
"tool-",
|
||||||
)
|
)
|
||||||
|
// Blank text (some models send
|
||||||
|
// a lone space) gets no bubble
|
||||||
const isContentPart =
|
const isContentPart =
|
||||||
part.type === "text" ||
|
(part.type === "text" &&
|
||||||
|
part.text.trim() !==
|
||||||
|
"") ||
|
||||||
part.type === "file"
|
part.type === "file"
|
||||||
|
|
||||||
if (isToolPart) {
|
if (isToolPart) {
|
||||||
@@ -911,30 +923,56 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
|
const toolPart = group
|
||||||
|
.parts[0] as ToolPartLike
|
||||||
|
const toolCallId =
|
||||||
|
toolPart.toolCallId
|
||||||
|
const isDisplayDiagram =
|
||||||
|
toolPart.type ===
|
||||||
|
"tool-display_diagram"
|
||||||
|
const validationState =
|
||||||
|
validationStates[
|
||||||
|
toolCallId
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallCard
|
<div
|
||||||
key={`${message.id}-tool-${group.startIndex}`}
|
key={`${message.id}-tool-${group.startIndex}`}
|
||||||
part={
|
>
|
||||||
group
|
<ToolCallCard
|
||||||
.parts[0] as ToolPartLike
|
part={
|
||||||
}
|
toolPart
|
||||||
expandedTools={
|
}
|
||||||
expandedTools
|
expandedTools={
|
||||||
}
|
expandedTools
|
||||||
setExpandedTools={
|
}
|
||||||
setExpandedTools
|
setExpandedTools={
|
||||||
}
|
setExpandedTools
|
||||||
onCopy={
|
}
|
||||||
copyMessageToClipboard
|
onCopy={
|
||||||
}
|
copyMessageToClipboard
|
||||||
copiedToolCallId={
|
}
|
||||||
copiedToolCallId
|
copiedToolCallId={
|
||||||
}
|
copiedToolCallId
|
||||||
copyFailedToolCallId={
|
}
|
||||||
copyFailedToolCallId
|
copyFailedToolCallId={
|
||||||
}
|
copyFailedToolCallId
|
||||||
dict={dict}
|
}
|
||||||
/>
|
dict={dict}
|
||||||
|
/>
|
||||||
|
{/* Show validation card for display_diagram tools */}
|
||||||
|
{isDisplayDiagram &&
|
||||||
|
validationState && (
|
||||||
|
<ValidationCard
|
||||||
|
state={
|
||||||
|
validationState
|
||||||
|
}
|
||||||
|
onImproveWithSuggestions={
|
||||||
|
onImproveWithSuggestions
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,12 +1086,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 =
|
||||||
@@ -1062,10 +1102,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"
|
||||||
>
|
>
|
||||||
@@ -1080,7 +1137,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[pdfKey]:
|
[sectionKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1088,13 +1145,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
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1239,32 @@ export function ChatMessageDisplay({
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
{message.role ===
|
||||||
|
"system" &&
|
||||||
|
(
|
||||||
|
message.metadata as
|
||||||
|
| {
|
||||||
|
openModelConfig?: boolean
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
)
|
||||||
|
?.openModelConfig &&
|
||||||
|
onOpenModelConfig && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={
|
||||||
|
onOpenModelConfig
|
||||||
|
}
|
||||||
|
className="mt-2 text-xs font-medium underline underline-offset-2 hover:opacity-80"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.errors
|
||||||
|
.llm
|
||||||
|
.openModelSettings
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+603
-280
File diff suppressed because it is too large
Load Diff
+222
-136
@@ -8,9 +8,10 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import { useEffect, useState } from "react"
|
||||||
import { useState } from "react"
|
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
import ExamplePanel from "@/components/chat-example-panel"
|
||||||
|
import Image from "@/components/image-with-basepath"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import type { Template } from "@/lib/template-storage"
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
|
|||||||
onDeleteSession?: (id: string) => void
|
onDeleteSession?: (id: string) => void
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
dict: {
|
dict: {
|
||||||
sessionHistory?: {
|
sessionHistory?: {
|
||||||
recentChats?: string
|
recentChats?: string
|
||||||
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
|
|||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
}
|
}
|
||||||
|
templates?: {
|
||||||
|
title?: string
|
||||||
|
myTemplates?: string
|
||||||
|
}
|
||||||
examples?: {
|
examples?: {
|
||||||
quickExamples?: string
|
quickExamples?: string
|
||||||
}
|
}
|
||||||
@@ -75,164 +84,241 @@ function formatSessionDate(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export function ChatLobby({
|
||||||
sessions,
|
sessions,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
dict,
|
dict,
|
||||||
}: ChatLobbyProps) {
|
}: ChatLobbyProps) {
|
||||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||||
// Delete confirmation dialog state
|
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||||
// Search filter for history
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
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
|
const hasHistory = sessions.length > 0
|
||||||
|
|
||||||
if (!hasHistory) {
|
if (!hasHistory) {
|
||||||
// Show full examples when no history
|
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{panelVisibility.myTemplates && (
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{panelVisibility.quickExamples && (
|
||||||
|
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||||
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
// Show history + collapsible examples when there are sessions
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{/* Recent Chats Section */}
|
{/* Recent Chats Section */}
|
||||||
<div className="mb-6">
|
{panelVisibility.recentChats && (
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
<div className="mb-6">
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
</p>
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
{/* Search Bar */}
|
</p>
|
||||||
<div className="relative mb-3">
|
{/* Search Bar */}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="relative mb-3">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
type="text"
|
<input
|
||||||
placeholder={
|
type="text"
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
placeholder={
|
||||||
"Search chats..."
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
}
|
"Search chats..."
|
||||||
value={searchQuery}
|
}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
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"
|
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
|
{searchQuery && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSearchQuery("")}
|
type="button"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
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>
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{sessions
|
<div className="space-y-2">
|
||||||
.filter((session) =>
|
{sessions
|
||||||
session.title
|
.filter((session) =>
|
||||||
|
session.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((session) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Ignore keys bubbling up from the delete button
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()),
|
.includes(searchQuery.toLowerCase()),
|
||||||
)
|
).length === 0 &&
|
||||||
.map((session) => (
|
searchQuery && (
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
<div
|
{dict.sessionHistory?.noResults ||
|
||||||
key={session.id}
|
"No chats found"}
|
||||||
role="button"
|
</p>
|
||||||
tabIndex={0}
|
)}
|
||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
||||||
onClick={() => onSelectSession(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectSession(session.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.thumbnailDataUrl ? (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={session.thumbnailDataUrl}
|
|
||||||
alt=""
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="object-contain w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<MessageSquare className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">
|
|
||||||
{session.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatSessionDate(
|
|
||||||
session.updatedAt,
|
|
||||||
dict.sessionHistory,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onDeleteSession && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSessionToDelete(session.id)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
title={dict.common.delete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.filter((s) =>
|
|
||||||
s.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
).length === 0 &&
|
|
||||||
searchQuery && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
{dict.sessionHistory?.noResults ||
|
|
||||||
"No chats found"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Examples Section */}
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.examples?.quickExamples || "Quick Examples"}
|
|
||||||
</span>
|
|
||||||
{examplesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{examplesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<ExamplePanel
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
minimal
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Plus } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type TemplateCreateInput,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
initialPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateCreateDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset form when dialog opens with the latest initialPrompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setPrompt(initialPrompt)
|
||||||
|
setPinned(false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, initialPrompt])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// React submit events bubble through the portal; keep them away from
|
||||||
|
// the chat form this dialog may be rendered in
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: TemplateCreateInput = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await createTemplate(input)
|
||||||
|
if (template) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create template:", err)
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{dict.templates.createTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.createDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt" className="text-foreground">
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-foreground">
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.templates.createButton}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Edit2 } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
template: Template | null
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
template,
|
||||||
|
onSuccess,
|
||||||
|
}: TemplateEditDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && template) {
|
||||||
|
setTitle(template.title || "")
|
||||||
|
setDescription(template.description || "")
|
||||||
|
setPrompt(template.prompt || "")
|
||||||
|
setPinned(template.pinned || false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, template])
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// React submit events bubble through the portal; keep them away from
|
||||||
|
// any form this dialog may be rendered in
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateTemplate(template.id, updates)
|
||||||
|
if (updated) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update template:", err)
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
{dict.templates.editTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.editDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-prompt"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-title"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="edit-pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.common.save}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit2,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
deleteTemplate,
|
||||||
|
duplicateTemplate,
|
||||||
|
exportTemplates,
|
||||||
|
getAllTemplates,
|
||||||
|
importTemplates,
|
||||||
|
incrementClickCount,
|
||||||
|
incrementRunCount,
|
||||||
|
searchTemplates,
|
||||||
|
type Template,
|
||||||
|
updateTemplate,
|
||||||
|
validateImportData,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||||
|
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||||
|
|
||||||
|
interface TemplatePanelProps {
|
||||||
|
setInput: (input: string) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||||
|
if (!timestamp) return neverUsedText
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = now - timestamp
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||||
|
if (diffMins < 1) return rtf.format(0, "minute")
|
||||||
|
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||||
|
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||||
|
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||||
|
} catch {
|
||||||
|
// Fallback if Intl.RelativeTimeFormat is not available
|
||||||
|
if (diffMins < 1) return "<1m ago"
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatePanel({
|
||||||
|
setInput,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
|
}: TemplatePanelProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||||
|
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [importMessage, setImportMessage] = useState<{
|
||||||
|
type: "success" | "error"
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
const result = await getAllTemplates()
|
||||||
|
setTemplates(result)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter templates by search query
|
||||||
|
const filteredTemplates = searchQuery.trim()
|
||||||
|
? searchTemplates(templates, searchQuery)
|
||||||
|
: templates
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates()
|
||||||
|
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||||
|
window.addEventListener("templatesChanged", loadTemplates)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("templatesChanged", loadTemplates)
|
||||||
|
}, [loadTemplates])
|
||||||
|
|
||||||
|
const handleCreateSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (template: Template) => {
|
||||||
|
setTemplateToEdit(template)
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDuplicate = async (template: Template) => {
|
||||||
|
const duplicated = await duplicateTemplate(
|
||||||
|
template.id,
|
||||||
|
dict.templates.copySuffix || "(copy)",
|
||||||
|
)
|
||||||
|
if (duplicated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (template: Template) => {
|
||||||
|
setTemplateToDelete(template)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!templateToDelete) return
|
||||||
|
const success = await deleteTemplate(templateToDelete.id)
|
||||||
|
if (success) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setTemplateToDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async (template: Template) => {
|
||||||
|
const updated = await updateTemplate(template.id, {
|
||||||
|
pinned: !template.pinned,
|
||||||
|
})
|
||||||
|
if (updated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle template card click - send directly or show confirmation
|
||||||
|
const handleTemplateClick = async (template: Template) => {
|
||||||
|
// If there's unsent content in the input, show confirmation dialog
|
||||||
|
if (currentInput.trim()) {
|
||||||
|
setTemplateToSend(template)
|
||||||
|
setConfirmSendDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No unsent content, send directly
|
||||||
|
await sendTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually send the template
|
||||||
|
const sendTemplate = async (template: Template) => {
|
||||||
|
// Increment click count only when actually sending
|
||||||
|
await incrementClickCount(template.id)
|
||||||
|
if (onSendTemplate) {
|
||||||
|
// Increment run count and update lastUsedAt
|
||||||
|
await incrementRunCount(template.id)
|
||||||
|
// Reload to show updated stats
|
||||||
|
loadTemplates()
|
||||||
|
// Call the send callback
|
||||||
|
onSendTemplate(template)
|
||||||
|
} else {
|
||||||
|
// Fallback: just fill the input if no send callback provided
|
||||||
|
setInput(template.prompt)
|
||||||
|
}
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation dialog - user confirmed to send template
|
||||||
|
const handleConfirmSend = async () => {
|
||||||
|
if (!templateToSend) return
|
||||||
|
await sendTemplate(templateToSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel - close dialog without sending
|
||||||
|
const handleCancelSend = () => {
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export templates to JSON file
|
||||||
|
const handleExport = () => {
|
||||||
|
if (templates.length === 0) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.exportEmpty || "No templates to export",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportData = exportTemplates(templates)
|
||||||
|
const json = JSON.stringify(exportData, null, 2)
|
||||||
|
const blob = new Blob([json], { type: "application/json" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||||
|
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.exportSuccess.replace(
|
||||||
|
"{count}",
|
||||||
|
String(templates.length),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates from JSON file
|
||||||
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
dict.templates.importNoFile || "Please select a JSON file",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Validate import data
|
||||||
|
const validation = validateImportData(data)
|
||||||
|
if (!validation.valid) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
validation.error || "Invalid data",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates with dedup-append strategy
|
||||||
|
const result = await importTemplates(data.templates, templates)
|
||||||
|
|
||||||
|
// Reload template list
|
||||||
|
await loadTemplates()
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.importSuccess
|
||||||
|
.replace("{imported}", String(result.imported))
|
||||||
|
.replace("{skipped}", String(result.skipped)),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the empty state and the list, so import works in both
|
||||||
|
const importInput = (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const importMessageBox = importMessage && (
|
||||||
|
<div
|
||||||
|
className={`text-xs px-3 py-2 rounded-lg ${
|
||||||
|
importMessage.type === "success"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importMessage.text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty state: no templates at all
|
||||||
|
if (!loading && templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.templates.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.templates.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<FileText className="w-8 h-8 text-primary/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
|
{dict.templates.emptyTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||||
|
{dict.templates.emptyDescription}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{dict.templates.createFirst}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
{importInput}
|
||||||
|
{importMessageBox && (
|
||||||
|
<div className="mt-3">{importMessageBox}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template list
|
||||||
|
return (
|
||||||
|
<div className="py-2 px-2 animate-fade-in">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={dict.templates.searchPlaceholder}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.createButton}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={templates.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={dict.templates.exportTemplates}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.exportTemplates}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={dict.templates.importTemplates}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
{importInput}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import message */}
|
||||||
|
{importMessageBox}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading
|
||||||
|
? // Loading skeleton
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${String(i)}`}
|
||||||
|
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
|
<div className="h-3 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: filteredTemplates.length === 0
|
||||||
|
? // Search empty state
|
||||||
|
!loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||||
|
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{dict.templates.searchNoResults}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: filteredTemplates.map((template) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Ignore keys bubbling up from the action buttons
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{template.title}
|
||||||
|
</div>
|
||||||
|
{template.pinned && (
|
||||||
|
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions and stats */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
|
{template.runCount > 0
|
||||||
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||||
|
: dict.templates.neverUsed}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTogglePin(template)
|
||||||
|
}}
|
||||||
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
|
template.pinned
|
||||||
|
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
template.pinned
|
||||||
|
? dict.templates
|
||||||
|
.unpin || "Unpin"
|
||||||
|
: dict.templates.pin ||
|
||||||
|
"Pin"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={dict.common.edit}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDuplicate(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={
|
||||||
|
dict.templates.duplicate ||
|
||||||
|
"Duplicate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteClick(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
template={templateToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.deleteTitle ||
|
||||||
|
"Delete this template?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.deleteDescription ||
|
||||||
|
"This will permanently delete this template. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||||
|
>
|
||||||
|
{dict.common.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Confirm Send Dialog - when there's unsent input */}
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmSendDialogOpen}
|
||||||
|
onOpenChange={setConfirmSendDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.confirmSendTitle ||
|
||||||
|
"Replace current input?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.confirmSendDescription ||
|
||||||
|
"You have unsent content in the input. Sending this template will replace it."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelSend}>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
|
{dict.templates.confirmSendButton ||
|
||||||
|
"Send Template"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,11 +20,17 @@ interface ToolCallCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
|
// Streamed or invalid input can hold anything: show only what React can
|
||||||
|
// render (an object in place of a string would crash the whole chat)
|
||||||
|
const shown = operations.filter(
|
||||||
|
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
||||||
|
)
|
||||||
|
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{operations.map((op, index) => (
|
{shown.map((op, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
key={`${op.operation}-${text(op.cell_id)}-${index}`}
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
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">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|||||||
{op.operation}
|
{op.operation}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
cell_id: {op.cell_id}
|
cell_id: {text(op.cell_id)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{op.new_xml && (
|
{text(op.new_xml) && (
|
||||||
<div className="px-3 py-2">
|
<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">
|
<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}
|
{text(op.new_xml)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -66,9 +72,9 @@ export function ToolCallCard({
|
|||||||
dict,
|
dict,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output } = part
|
const { state, input, output, errorText } = part
|
||||||
// Default to collapsed if tool is complete, expanded if still streaming
|
// Default to expanded for all states (user can manually collapse if needed)
|
||||||
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = part.type?.replace("tool-", "")
|
||||||
const isCopied = copiedToolCallId === callId
|
const isCopied = copiedToolCallId === callId
|
||||||
|
|
||||||
@@ -92,6 +98,16 @@ export function ToolCallCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incomplete XML means the output hit the length limit, unless the user
|
||||||
|
// stopped the generation themselves. Without an input the JSON was
|
||||||
|
// broken (the server repairs JSON cut short by the limit).
|
||||||
|
const isTruncated =
|
||||||
|
state === "output-error" &&
|
||||||
|
errorText !== "Stopped by user" &&
|
||||||
|
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||||
|
typeof input?.xml === "string" &&
|
||||||
|
!isMxCellXmlComplete(input.xml)
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
let textToCopy = ""
|
let textToCopy = ""
|
||||||
|
|
||||||
@@ -161,22 +177,15 @@ export function ToolCallCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{state === "output-error" &&
|
{state === "output-error" &&
|
||||||
(() => {
|
(isTruncated ? (
|
||||||
// Check if this is a truncation (incomplete XML) vs real error
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
const isTruncated =
|
Truncated
|
||||||
(toolName === "display_diagram" ||
|
</span>
|
||||||
toolName === "append_diagram") &&
|
) : (
|
||||||
!isMxCellXmlComplete(input?.xml)
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||||
return isTruncated ? (
|
Error
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
</span>
|
||||||
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 && (
|
{input && Object.keys(input).length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -195,7 +204,22 @@ export function ToolCallCard({
|
|||||||
{input && isExpanded && (
|
{input && isExpanded && (
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
{typeof input === "object" && input.xml ? (
|
{typeof input === "object" && input.xml ? (
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
state === "input-streaming" ||
|
||||||
|
state === "input-available" ? (
|
||||||
|
<pre
|
||||||
|
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
"var(--font-mono), ui-monospace, monospace",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{input.xml}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
)
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
input.operations &&
|
input.operations &&
|
||||||
Array.isArray(input.operations) ? (
|
Array.isArray(input.operations) ? (
|
||||||
@@ -209,23 +233,16 @@ export function ToolCallCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{output &&
|
{/* AI SDK stores tool errors in errorText */}
|
||||||
state === "output-error" &&
|
{state === "output-error" && (errorText || output) && (
|
||||||
(() => {
|
<div
|
||||||
const isTruncated =
|
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
(toolName === "display_diagram" ||
|
>
|
||||||
toolName === "append_diagram") &&
|
{isTruncated
|
||||||
!isMxCellXmlComplete(input?.xml)
|
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
||||||
return (
|
: (errorText ?? output)}
|
||||||
<div
|
</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 */}
|
{/* Show get_shape_library output on success */}
|
||||||
{output &&
|
{output &&
|
||||||
toolName === "get_shape_library" &&
|
toolName === "get_shape_library" &&
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
export interface DiagramOperation {
|
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
export type { DiagramOperation }
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolPartLike {
|
export interface ToolPartLike {
|
||||||
type: string
|
type: string
|
||||||
@@ -13,4 +11,5 @@ export interface ToolPartLike {
|
|||||||
operations?: DiagramOperation[]
|
operations?: DiagramOperation[]
|
||||||
} & Record<string, unknown>
|
} & Record<string, unknown>
|
||||||
output?: string
|
output?: string
|
||||||
|
errorText?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { wrapWithMxFile } from "@/lib/utils"
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
const DEV_XML_PRESETS: Record<string, string> = {
|
const DEV_XML_PRESETS: Record<string, string> = {
|
||||||
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Display the final diagram
|
// Display the final diagram
|
||||||
const fullXml = wrapWithMxFile(xml)
|
const prepared = prepareNewDiagram(xml)
|
||||||
onDisplayChart(fullXml)
|
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||||
|
|
||||||
setIsSimulating(false)
|
setIsSimulating(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FileCode, FileText, Link, 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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
},
|
||||||
|
)
|
||||||
+690
-691
File diff suppressed because it is too large
Load Diff
+261
-120
@@ -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,40 +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",
|
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
@@ -62,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)
|
||||||
@@ -90,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(
|
||||||
@@ -102,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)
|
||||||
@@ -151,7 +158,7 @@ export function ModelSelector({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="inline-block">
|
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -160,7 +167,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
|
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -169,7 +176,7 @@ export function ModelSelector({
|
|||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
{/* show/hide visible label based on measured width */}
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<span className="text-xs truncate">
|
<span className="min-w-0 truncate text-xs">
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.modelId
|
? selectedModel.modelId
|
||||||
: dict.modelConfig.default}
|
: dict.modelConfig.default}
|
||||||
@@ -190,113 +197,247 @@ export function ModelSelector({
|
|||||||
<ModelSelectorInput
|
<ModelSelectorInput
|
||||||
placeholder={dict.modelConfig.searchModels}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
/>
|
/>
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<ModelSelectorEmpty>
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{displayModels.length === 0 && models.length > 0
|
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||||
? dict.modelConfig.noVerifiedModels
|
<ModelSelectorEmpty>
|
||||||
: dict.modelConfig.noModelsFound}
|
{displayModels.length === 0 &&
|
||||||
</ModelSelectorEmpty>
|
models.length > 0
|
||||||
|
? dict.modelConfig.noVerifiedModels
|
||||||
|
: dict.modelConfig.noModelsFound}
|
||||||
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
{/* Server Default Option */}
|
{/* Server Default Option - only show when no server models are configured */}
|
||||||
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
{serverModels.length === 0 && (
|
||||||
<ModelSelectorItem
|
<ModelSelectorGroup
|
||||||
value="__server_default__"
|
heading={dict.modelConfig.default}
|
||||||
onSelect={handleSelect}
|
>
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
!selectedModelId && "bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
!selectedModelId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.serverDefault}
|
|
||||||
</ModelSelectorName>
|
|
||||||
</ModelSelectorItem>
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
|
|
||||||
{/* Configured Models by Provider */}
|
|
||||||
{Array.from(groupedModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={providerLabel}
|
|
||||||
heading={providerLabel}
|
|
||||||
>
|
|
||||||
{providerModels.map((model) => (
|
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
value="__server_default__"
|
||||||
value={model.modelId}
|
onSelect={handleSelect}
|
||||||
onSelect={() =>
|
className={cn(
|
||||||
handleSelect(model.id)
|
"cursor-pointer",
|
||||||
}
|
!selectedModelId && "bg-accent",
|
||||||
className="cursor-pointer"
|
)}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
selectedModelId === model.id
|
!selectedModelId
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ModelSelectorLogo
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
{model.modelId}
|
{dict.modelConfig.serverDefault}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
{model.validated !== true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
))}
|
</ModelSelectorGroup>
|
||||||
</ModelSelectorGroup>
|
)}
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Configure Option */}
|
{/* Server Models Section */}
|
||||||
<ModelSelectorSeparator />
|
{serverModels.length > 0 && (
|
||||||
<ModelSelectorGroup>
|
<>
|
||||||
<ModelSelectorItem
|
<ModelSelectorSectionHeader
|
||||||
value="__configure__"
|
icon={<Monitor />}
|
||||||
onSelect={handleSelect}
|
label={
|
||||||
className="cursor-pointer"
|
dict.modelConfig.serverModels
|
||||||
>
|
}
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
/>
|
||||||
<ModelSelectorName>
|
{Array.from(
|
||||||
{dict.modelConfig.configureModels}
|
groupedServerModels.entries(),
|
||||||
</ModelSelectorName>
|
).map(
|
||||||
</ModelSelectorItem>
|
([
|
||||||
</ModelSelectorGroup>
|
providerLabel,
|
||||||
{/* Info text */}
|
{
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
provider,
|
||||||
{showUnvalidatedModels
|
models: providerModels,
|
||||||
? dict.modelConfig.allModelsShown
|
},
|
||||||
: dict.modelConfig.onlyVerifiedShown}
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`server-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
// Unique value so same-named models highlight
|
||||||
|
// separately; keywords keep search by name
|
||||||
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.isDefault && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Models Section */}
|
||||||
|
{userModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<ModelSelectorSeparator />
|
||||||
|
)}
|
||||||
|
<ModelSelectorSectionHeader
|
||||||
|
icon={<User />}
|
||||||
|
label={dict.modelConfig.userModels}
|
||||||
|
/>
|
||||||
|
{Array.from(
|
||||||
|
groupedUserModels.entries(),
|
||||||
|
).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
models: providerModels,
|
||||||
|
},
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`user-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !==
|
||||||
|
true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModelSelectorList>
|
||||||
</div>
|
</div>
|
||||||
</ModelSelectorList>
|
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||||
|
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||||
|
{onConfigure && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__configure_models__"
|
||||||
|
onSelect={() => {
|
||||||
|
onConfigure()
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.configureModels}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||||
|
{showUnvalidatedModels
|
||||||
|
? dict.modelConfig.allModelsShown
|
||||||
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Key, Link2, Tag } from "lucide-react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import {
|
||||||
|
chatRequestUrl,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderName,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||||
|
// user dialog, write-only masked for the admin panel — supplied via
|
||||||
|
// renderSecret. That (and the optional test action) are the only genuine
|
||||||
|
// differences between the two screens; the field structure is shared here.
|
||||||
|
export type SecretField =
|
||||||
|
| "apiKey"
|
||||||
|
| "awsAccessKeyId"
|
||||||
|
| "awsSecretAccessKey"
|
||||||
|
| "vertexApiKey"
|
||||||
|
|
||||||
|
// AWS regions offered for Bedrock (shared by both screens)
|
||||||
|
export const AWS_REGIONS: Array<[string, string]> = [
|
||||||
|
["us-east-1", "N. Virginia"],
|
||||||
|
["us-east-2", "Ohio"],
|
||||||
|
["us-west-2", "Oregon"],
|
||||||
|
["eu-west-1", "Ireland"],
|
||||||
|
["eu-west-2", "London"],
|
||||||
|
["eu-west-3", "Paris"],
|
||||||
|
["eu-central-1", "Frankfurt"],
|
||||||
|
["ap-south-1", "Mumbai"],
|
||||||
|
["ap-northeast-1", "Tokyo"],
|
||||||
|
["ap-northeast-2", "Seoul"],
|
||||||
|
["ap-southeast-1", "Singapore"],
|
||||||
|
["ap-southeast-2", "Sydney"],
|
||||||
|
["sa-east-1", "São Paulo"],
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ProviderCredentialsFieldsProps {
|
||||||
|
provider: ProviderName
|
||||||
|
// Plain (non-secret) field values — secrets are owned by renderSecret
|
||||||
|
name?: string
|
||||||
|
baseUrl?: string
|
||||||
|
awsRegion?: string
|
||||||
|
disabled?: boolean
|
||||||
|
// Update a plain text field
|
||||||
|
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||||
|
// Render the control for a secret field. The caller may include trailing
|
||||||
|
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||||
|
// shared component only supplies the label above it.
|
||||||
|
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||||
|
// Extra content after the fields — used for the Bedrock test row and the
|
||||||
|
// EdgeOne test button, which aren't beside a credential input.
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name + per-provider credential inputs, shared by the user
|
||||||
|
// ModelConfigDialog and the admin Models panel.
|
||||||
|
export function ProviderCredentialsFields({
|
||||||
|
provider,
|
||||||
|
name,
|
||||||
|
baseUrl,
|
||||||
|
awsRegion,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
renderSecret,
|
||||||
|
footer,
|
||||||
|
}: ProviderCredentialsFieldsProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const info = PROVIDER_INFO[provider]
|
||||||
|
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||||
|
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||||
|
})
|
||||||
|
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||||
|
|
||||||
|
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||||
|
if (provider === "edgeone") {
|
||||||
|
return <div className="space-y-5">{footer}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Display Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="provider-name"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.displayName}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="provider-name"
|
||||||
|
value={name ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange("name", e.target.value)}
|
||||||
|
placeholder={info.label}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "bedrock" ? (
|
||||||
|
<>
|
||||||
|
{/* AWS Access Key ID */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-access-key-id"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsAccessKeyId}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "awsAccessKeyId",
|
||||||
|
id: "aws-access-key-id",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Secret Access Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-secret-access-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsSecretAccessKey}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "awsSecretAccessKey",
|
||||||
|
id: "aws-secret-access-key",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Region */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-region"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsRegion}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={awsRegion || ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => onChange("awsRegion", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="aws-region"
|
||||||
|
className="h-9 font-mono text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={dict.modelConfig.selectRegion}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
{AWS_REGIONS.map(([region, label]) => (
|
||||||
|
<SelectItem key={region} value={region}>
|
||||||
|
{region} ({label})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : provider === "vertexai" ? (
|
||||||
|
<>
|
||||||
|
{/* Vertex AI API Key (Express Mode) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="vertex-api-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.apiKey}
|
||||||
|
</Label>
|
||||||
|
{renderSecret({
|
||||||
|
field: "vertexApiKey",
|
||||||
|
id: "vertex-api-key",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL (optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="vertex-base-url"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{baseUrlLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="vertex-base-url"
|
||||||
|
value={baseUrl ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("baseUrl", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={dict.modelConfig.customEndpoint}
|
||||||
|
className="h-9 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* API Key, with a link to where the provider issues keys */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="api-key"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.apiKey}
|
||||||
|
{provider === "ollama" &&
|
||||||
|
` ${dict.modelConfig.optional}`}
|
||||||
|
</Label>
|
||||||
|
{info.apiKeyUrl && (
|
||||||
|
<a
|
||||||
|
href={info.apiKeyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{dict.modelConfig.getApiKey}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="base-url"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{baseUrlLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="base-url"
|
||||||
|
value={baseUrl ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("baseUrl", e.target.value)
|
||||||
|
}
|
||||||
|
// Drop a pasted endpoint path such as /chat/completions
|
||||||
|
onBlur={(e) => {
|
||||||
|
const normalized = normalizeBaseUrl(
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
if (normalized !== e.target.value) {
|
||||||
|
onChange("baseUrl", normalized)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
info.defaultBaseUrl ||
|
||||||
|
dict.modelConfig.customEndpoint
|
||||||
|
}
|
||||||
|
className="h-9 rounded-xl font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{requestUrl && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||||
|
{formatMessage(dict.modelConfig.requestUrl, {
|
||||||
|
url: requestUrl,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "minimax" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.modelConfig.minimaxBaseUrlHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{provider === "mimo" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.modelConfig.mimoBaseUrlHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||||
|
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||||
|
// that have no logo there
|
||||||
|
export function ProviderLogo({
|
||||||
|
provider,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
provider: ProviderName
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (provider === "bedrock") {
|
||||||
|
return <Cloud className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
if (provider === "sglang") {
|
||||||
|
return <Server className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
if (provider === "doubao") {
|
||||||
|
return <Sparkles className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-4 dark:invert", className)}
|
||||||
|
height={16}
|
||||||
|
src={`https://models.dev/logos/${logoName}.svg`}
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,9 +23,22 @@ export function QuotaLimitToast({
|
|||||||
}: QuotaLimitToastProps) {
|
}: QuotaLimitToastProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isTokenLimit = type === "token"
|
const isTokenLimit = type === "token"
|
||||||
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||||
const formatNumber = (n: number) =>
|
const formatNumber = (n: number) =>
|
||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||||
|
|
||||||
|
const quotaMessage = isTokenLimit
|
||||||
|
? isSelfHosted
|
||||||
|
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||||
|
: dict.quota.messageToken
|
||||||
|
: isSelfHosted
|
||||||
|
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||||
|
: dict.quota.messageApi
|
||||||
|
|
||||||
|
const tipHtml = isSelfHosted
|
||||||
|
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
||||||
|
: dict.quota.tip
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -71,19 +84,24 @@ export function QuotaLimitToast({
|
|||||||
</div>
|
</div>
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||||
<p>
|
<p>{quotaMessage}</p>
|
||||||
{isTokenLimit
|
{!isSelfHosted && (
|
||||||
? dict.quota.messageToken
|
<p
|
||||||
: dict.quota.messageApi}
|
dangerouslySetInnerHTML={{
|
||||||
</p>
|
__html: formatMessage(
|
||||||
|
dict.quota.doubaoSponsorship,
|
||||||
|
{
|
||||||
|
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p
|
<p
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
__html: tipHtml,
|
||||||
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
|
||||||
<p>{dict.quota.reset}</p>
|
<p>{dict.quota.reset}</p>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
@@ -101,24 +119,28 @@ export function QuotaLimitToast({
|
|||||||
{dict.quota.configModel}
|
{dict.quota.configModel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a
|
{!isSelfHosted && (
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
<>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<FaGithub className="w-3.5 h-3.5" />
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
{dict.quota.selfHost}
|
>
|
||||||
</a>
|
<FaGithub className="w-3.5 h-3.5" />
|
||||||
<a
|
{dict.quota.selfHost}
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
</a>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<Coffee className="w-3.5 h-3.5" />
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
{dict.quota.sponsor}
|
>
|
||||||
</a>
|
<Coffee className="w-3.5 h-3.5" />
|
||||||
|
{dict.quota.sponsor}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg"
|
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,6 +74,11 @@ export function SaveDialog({
|
|||||||
label: dict.save.formats.svg,
|
label: dict.save.formats.svg,
|
||||||
extension: ".svg",
|
extension: ".svg",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "xmlsvg" as const,
|
||||||
|
label: dict.save.formats.xmlsvg,
|
||||||
|
extension: ".drawio.svg",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||||
|
|||||||
+238
-57
@@ -1,8 +1,8 @@
|
|||||||
"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 { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
|
||||||
@@ -56,22 +58,28 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
en: "English",
|
en: "English",
|
||||||
zh: "中文",
|
zh: "中文",
|
||||||
ja: "日本語",
|
ja: "日本語",
|
||||||
|
"zh-Hant": "繁體中文",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onCloseProtectionChange?: (enabled: boolean) => void
|
drawioUi: DrawioTheme
|
||||||
drawioUi: "min" | "sketch"
|
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||||
onToggleDrawioUi: () => void
|
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
|
vlmValidationEnabled?: boolean
|
||||||
|
onVlmValidationChange?: (value: boolean) => void
|
||||||
|
onOpenModelConfig?: () => void
|
||||||
|
customSystemMessage?: string
|
||||||
|
onCustomSystemMessageChange?: (value: string) => void
|
||||||
|
maxOutputTokens?: string
|
||||||
|
onMaxOutputTokensChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
@@ -84,20 +92,25 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onDrawioUiChange,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
|
vlmValidationEnabled = false,
|
||||||
|
onVlmValidationChange = () => {},
|
||||||
|
onOpenModelConfig,
|
||||||
|
customSystemMessage = "",
|
||||||
|
onCustomSystemMessageChange = () => {},
|
||||||
|
maxOutputTokens = "",
|
||||||
|
onMaxOutputTokensChange = () => {},
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const search = useSearchParams()
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
const [closeProtection, setCloseProtection] = useState(true)
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
@@ -106,14 +119,31 @@ function SettingsContent({
|
|||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
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)
|
// Proxy settings state (Electron only)
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
const [httpProxy, setHttpProxy] = useState("")
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
const [httpsProxy, setHttpsProxy] = useState("")
|
||||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||||
if (getStoredAccessCodeRequired() !== null) return
|
// the access code input if the server requires it. This fixes the case
|
||||||
|
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
||||||
|
// configured) would hide the access code input.
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch(getApiEndpoint("/api/config"))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -129,10 +159,9 @@ function SettingsContent({
|
|||||||
setAccessCodeRequired(required)
|
setAccessCodeRequired(required)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Don't cache on error - allow retry on next mount
|
// Keep existing cached value on error
|
||||||
setAccessCodeRequired(false)
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [open])
|
||||||
|
|
||||||
// Detect current language from pathname
|
// Detect current language from pathname
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,17 +180,22 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedCloseProtection = localStorage.getItem(
|
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
|
||||||
)
|
|
||||||
// Default to true if not set
|
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
|
||||||
|
|
||||||
const storedSendShortcut = localStorage.getItem(
|
const storedSendShortcut = localStorage.getItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_KEYS.sendShortcut,
|
||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
// Load proxy settings (Electron only)
|
||||||
@@ -178,6 +212,13 @@ function SettingsContent({
|
|||||||
// Save locale to localStorage for persistence across restarts
|
// Save locale to localStorage for persistence across restarts
|
||||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||||
|
|
||||||
|
// Notify Electron main process to update its menu language
|
||||||
|
if (window.electronAPI?.setUserLocale) {
|
||||||
|
window.electronAPI.setUserLocale(lang).catch((error) => {
|
||||||
|
console.error("Failed to sync locale with Electron:", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const parts = pathname.split("/")
|
const parts = pathname.split("/")
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
parts[1] = lang
|
parts[1] = lang
|
||||||
@@ -270,7 +311,7 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -280,8 +321,29 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{/* API Keys & Models */}
|
||||||
|
{onOpenModelConfig && (
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.apiKeysModels}
|
||||||
|
description={dict.settings.apiKeysModelsDescription}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false)
|
||||||
|
onOpenModelConfig()
|
||||||
|
}}
|
||||||
|
aria-label={dict.settings.apiKeysModels}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Access Code (conditional) */}
|
{/* Access Code (conditional) */}
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<div className="py-4 first:pt-0 space-y-3">
|
<div className="py-4 first:pt-0 space-y-3">
|
||||||
@@ -375,42 +437,40 @@ function SettingsContent({
|
|||||||
{/* Draw.io Style */}
|
{/* Draw.io Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.drawioStyle}
|
label={dict.settings.drawioStyle}
|
||||||
description={`${dict.settings.drawioStyleDescription} ${
|
description={dict.settings.drawioStyleDescription}
|
||||||
drawioUi === "min"
|
|
||||||
? dict.settings.minimal
|
|
||||||
: dict.settings.sketch
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Select
|
||||||
id="drawio-ui"
|
value={drawioUi}
|
||||||
variant="outline"
|
onValueChange={(v) =>
|
||||||
onClick={onToggleDrawioUi}
|
onDrawioUiChange(v as DrawioTheme)
|
||||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
}
|
||||||
>
|
>
|
||||||
{dict.settings.switchTo}{" "}
|
<SelectTrigger
|
||||||
{drawioUi === "min"
|
id="drawio-ui-select"
|
||||||
? dict.settings.sketch
|
aria-label={dict.settings.drawioStyle}
|
||||||
: dict.settings.minimal}
|
className="w-[120px] h-9 rounded-xl"
|
||||||
</Button>
|
>
|
||||||
</SettingItem>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
{/* Close Protection */}
|
<SelectContent>
|
||||||
<SettingItem
|
<SelectItem value="kennedy">
|
||||||
label={dict.settings.closeProtection}
|
{dict.settings.themeDefault}
|
||||||
description={dict.settings.closeProtectionDescription}
|
</SelectItem>
|
||||||
>
|
<SelectItem value="atlas">Atlas</SelectItem>
|
||||||
<Switch
|
<SelectItem value="dark">
|
||||||
id="close-protection"
|
{dict.settings.themeDark}
|
||||||
checked={closeProtection}
|
</SelectItem>
|
||||||
onCheckedChange={(checked) => {
|
<SelectItem value="min">
|
||||||
setCloseProtection(checked)
|
{dict.settings.themeMinimal}
|
||||||
localStorage.setItem(
|
</SelectItem>
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
<SelectItem value="sketch">
|
||||||
checked.toString(),
|
{dict.settings.themeSketch}
|
||||||
)
|
</SelectItem>
|
||||||
onCloseProtectionChange?.(checked)
|
<SelectItem value="simple">
|
||||||
}}
|
{dict.settings.themeSimple}
|
||||||
/>
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
@@ -432,6 +492,127 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Panel Visibility */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.panelVisibility}
|
||||||
|
description={dict.settings.panelVisibilityDescription}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-recent-chats"
|
||||||
|
checked={showRecentChats}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showRecentChats,
|
||||||
|
v,
|
||||||
|
setShowRecentChats,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showRecentChats}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-my-templates"
|
||||||
|
checked={showMyTemplates}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showMyTemplates,
|
||||||
|
v,
|
||||||
|
setShowMyTemplates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showMyTemplates}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-quick-examples"
|
||||||
|
checked={showQuickExamples}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showQuickExamples,
|
||||||
|
v,
|
||||||
|
setShowQuickExamples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showQuickExamples}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* VLM Diagram Validation */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.diagramValidation}
|
||||||
|
description={dict.settings.diagramValidationDescription}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="vlm-validation"
|
||||||
|
checked={vlmValidationEnabled}
|
||||||
|
onCheckedChange={onVlmValidationChange}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{vlmValidationEnabled
|
||||||
|
? dict.settings.enabled
|
||||||
|
: dict.settings.disabled}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Custom System Message */}
|
||||||
|
<div className="py-4 space-y-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="custom-system-message"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{dict.settings.customSystemMessage}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.customSystemMessageDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="custom-system-message"
|
||||||
|
value={customSystemMessage}
|
||||||
|
onChange={(e) =>
|
||||||
|
onCustomSystemMessageChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
dict.settings.customSystemMessagePlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[80px] max-h-[160px] text-sm"
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Output Tokens */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.maxOutputTokens}
|
||||||
|
description={dict.settings.maxOutputTokensDescription}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="max-output-tokens"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={maxOutputTokens}
|
||||||
|
onChange={(e) =>
|
||||||
|
onMaxOutputTokensChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="64000"
|
||||||
|
className="h-9 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
{/* Send Shortcut */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.sendShortcut}
|
label={dict.settings.sendShortcut}
|
||||||
@@ -454,7 +635,7 @@ function SettingsContent({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="send-shortcut-select"
|
id="send-shortcut-select"
|
||||||
className="w-[170px] h-9 rounded-xl"
|
className="w-auto h-9 rounded-xl"
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+227
-164
@@ -1,28 +1,37 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||||
import { toast } from "sonner"
|
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 {
|
import {
|
||||||
extractDiagramXML,
|
BLANK_MXFILE,
|
||||||
isRealDiagram,
|
normalizeToMxfile,
|
||||||
validateAndFixXml,
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
} from "../lib/utils"
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
|
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||||
|
chartXMLRef: React.MutableRefObject<string>
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
// Both return the export's tag (empty when draw.io is not there yet)
|
||||||
handleExportWithoutHistory: () => void
|
handleExport: () => string
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
handleExportWithoutHistory: () => string
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
// Pending exports by tag; a history or plain export's resolver gets the
|
||||||
handleDiagramExport: (data: any) => void
|
// first page's XML
|
||||||
|
exportResolversRef: React.MutableRefObject<
|
||||||
|
Record<string, (data: string, xml?: string) => void>
|
||||||
|
>
|
||||||
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
|
handleDiagramExport: (data: EventExport) => void
|
||||||
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -31,6 +40,7 @@ interface DiagramContextType {
|
|||||||
successMessage?: string,
|
successMessage?: string,
|
||||||
) => void
|
) => void
|
||||||
getThumbnailSvg: () => Promise<string | null>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
|
captureValidationPng: () => Promise<string | null>
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -40,6 +50,12 @@ interface DiagramContextType {
|
|||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
// Every export carries a tag in the request's `message` field. draw.io
|
||||||
|
// echoes the request back in the export event, so each result reaches its
|
||||||
|
// own caller. Tags end in a request number, so a late result never answers
|
||||||
|
// a newer request.
|
||||||
|
type ExportTag = "thumbnail" | "validation"
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("")
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||||
@@ -50,11 +66,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
// Pending exports, keyed by their export tag
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
const exportResolversRef = useRef<
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
Record<string, (data: string, xml?: string) => void>
|
||||||
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
>({})
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
// Pending history exports: the document each one was asked for
|
||||||
|
const historyXmlRef = useRef(new Map<string, string>())
|
||||||
|
const exportSeqRef = useRef(0)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
const chartXMLRef = useRef<string>("")
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
@@ -63,6 +81,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
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 = () => {
|
||||||
@@ -70,81 +92,85 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXMLRef in sync with state for restoration after remount
|
// Update chartXML and its ref together, so callbacks that read the ref
|
||||||
useEffect(() => {
|
// (export handler, autosave) see the new value right away
|
||||||
chartXMLRef.current = chartXML
|
const updateChartXML = (xml: string) => {
|
||||||
}, [chartXML])
|
chartXMLRef.current = xml
|
||||||
|
setChartXML(xml)
|
||||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
}
|
||||||
useEffect(() => {
|
|
||||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
|
||||||
if (!isDrawioReady) {
|
|
||||||
hasDiagramRestoredRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only restore once per ready cycle
|
|
||||||
if (hasDiagramRestoredRef.current) return
|
|
||||||
hasDiagramRestoredRef.current = true
|
|
||||||
|
|
||||||
// Restore diagram from ref if we have one
|
|
||||||
const xmlToRestore = chartXMLRef.current
|
|
||||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
|
||||||
drawioRef.current.load({ xml: xmlToRestore })
|
|
||||||
}
|
|
||||||
}, [isDrawioReady])
|
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
|
||||||
const saveResolverRef = useRef<{
|
|
||||||
resolver: ((data: string) => void) | null
|
|
||||||
format: ExportFormat | null
|
|
||||||
}>({ resolver: null, format: null })
|
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (drawioRef.current) {
|
if (!drawioRef.current) return ""
|
||||||
// Mark that this export should be saved to history
|
// Save this export to history, with the document shown now:
|
||||||
expectHistoryExportRef.current = true
|
// chartXML can change before the result comes back
|
||||||
drawioRef.current.exportDiagram({
|
const tag = `history-${++exportSeqRef.current}`
|
||||||
format: "xmlsvg",
|
historyXmlRef.current.set(tag, chartXMLRef.current)
|
||||||
})
|
drawioRef.current.exportDiagram({
|
||||||
}
|
format: "xmlsvg",
|
||||||
|
message: tag,
|
||||||
|
})
|
||||||
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportWithoutHistory = () => {
|
const handleExportWithoutHistory = () => {
|
||||||
if (drawioRef.current) {
|
if (!drawioRef.current) return ""
|
||||||
// Export without saving to history (for edit_diagram fetching current state)
|
// Export without saving to history (for edit_diagram fetching current state)
|
||||||
drawioRef.current.exportDiagram({
|
const tag = `fetch-${++exportSeqRef.current}`
|
||||||
format: "xmlsvg",
|
drawioRef.current.exportDiagram({
|
||||||
})
|
format: "xmlsvg",
|
||||||
}
|
message: tag,
|
||||||
|
})
|
||||||
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
// event) and wait for that result. Resolves to null on timeout, which is
|
||||||
|
// expected occasionally.
|
||||||
|
// (Reads refs only, so it keeps one identity)
|
||||||
|
const requestTaggedExport = useCallback(
|
||||||
|
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
const id = `${tag}-${++exportSeqRef.current}`
|
||||||
|
const finish = (value: string | null) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
delete exportResolversRef.current[id]
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
exportResolversRef.current[id] = finish
|
||||||
|
drawioRef.current?.exportDiagram({ format, message: id })
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||||
|
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||||
|
// renders this provider again (latestSvg), which would otherwise start
|
||||||
|
// the next save
|
||||||
|
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXML)) return null
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
try {
|
|
||||||
const svgData = await Promise.race([
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
resolverRef.current = resolve
|
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
|
||||||
}),
|
|
||||||
new Promise<string>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
// xmlsvg exports return an SVG data URL
|
||||||
|
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||||
|
if (svgData?.startsWith("data:image/svg")) {
|
||||||
// Update latestSvg so it's available for future saves
|
// Update latestSvg so it's available for future saves
|
||||||
if (svgData?.includes("<svg")) {
|
setLatestSvg(svgData)
|
||||||
setLatestSvg(svgData)
|
return svgData
|
||||||
return svgData
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
// Timeout is expected occasionally - don't log as error
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}, [requestTaggedExport])
|
||||||
|
|
||||||
|
// Capture current diagram as PNG for VLM validation
|
||||||
|
const captureValidationPng = async (): Promise<string | null> => {
|
||||||
|
if (!drawioRef.current) return null
|
||||||
|
// Don't export if diagram is empty
|
||||||
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
|
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||||
|
// PNG data should be a base64 data URL
|
||||||
|
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (
|
||||||
@@ -153,9 +179,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
): string | null => {
|
): string | null => {
|
||||||
let xmlToLoad = chart
|
let xmlToLoad = chart
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal use)
|
// Validate XML structure before loading (unless skipped for internal
|
||||||
|
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||||
|
// tool handlers check model XML strictly before it gets here.
|
||||||
if (!skipValidation) {
|
if (!skipValidation) {
|
||||||
const validation = validateAndFixXml(chart)
|
const validation = validateAndFixXml(chart, { strict: false })
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[loadDiagram] Validation error:",
|
"[loadDiagram] Validation error:",
|
||||||
@@ -174,7 +202,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||||
setChartXML(xmlToLoad)
|
updateChartXML(xmlToLoad)
|
||||||
|
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
drawioRef.current.load({
|
drawioRef.current.load({
|
||||||
@@ -185,49 +213,68 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: any) => {
|
const handleDiagramExport = (data: EventExport) => {
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Thumbnail, validation PNG and file save exports go only to their
|
||||||
if (saveResolverRef.current.resolver) {
|
// own caller
|
||||||
const format = saveResolverRef.current.format
|
const tag = data.message?.message
|
||||||
saveResolverRef.current.resolver(data.data)
|
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
|
||||||
saveResolverRef.current = { resolver: null, format: null }
|
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
return
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
|
||||||
if (format === "png" || format === "svg") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't write chartXML here: exports don't change the diagram, and
|
||||||
|
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||||
|
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||||
|
// up to date with the full uncompressed multi-page document (#879).
|
||||||
const extractedXML = extractDiagramXML(data.data)
|
const extractedXML = extractDiagramXML(data.data)
|
||||||
setChartXML(extractedXML)
|
|
||||||
setLatestSvg(data.data)
|
setLatestSvg(data.data)
|
||||||
|
|
||||||
// Only add to history if this was a user-initiated export
|
// Only add to history if this was a user-initiated export
|
||||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||||
const MAX_HISTORY_SIZE = 20
|
const MAX_HISTORY_SIZE = 20
|
||||||
if (expectHistoryExportRef.current) {
|
const askedXml =
|
||||||
|
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
|
||||||
|
if (askedXml !== undefined) {
|
||||||
|
historyXmlRef.current.delete(tag as string)
|
||||||
|
// Store the full multi-page document (extractedXML is only the
|
||||||
|
// first page), so restoring a version keeps every page
|
||||||
|
const historyXml = askedXml || extractedXML
|
||||||
setDiagramHistory((prev) => {
|
setDiagramHistory((prev) => {
|
||||||
const newHistory = [
|
const newHistory = [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
svg: data.data,
|
svg: data.data,
|
||||||
xml: extractedXML,
|
xml: historyXml,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||||
})
|
})
|
||||||
expectHistoryExportRef.current = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolverRef.current) {
|
// The chat's own export (onFetchChart), not another one in flight
|
||||||
resolverRef.current(extractedXML)
|
const resolve =
|
||||||
resolverRef.current = null
|
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||||
|
if (resolve) {
|
||||||
|
delete exportResolversRef.current[tag as string]
|
||||||
|
resolve(extractedXML)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// react-drawio registers this callback once per iframe mount, so it must
|
||||||
|
// read refs: state captured in its closure would stay stale after a remount
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram but
|
||||||
|
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||||
|
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateChartXML(data.xml)
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = BLANK_MXFILE
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
loadDiagram(emptyDiagram, true)
|
loadDiagram(emptyDiagram, true)
|
||||||
setLatestSvg("")
|
setLatestSvg("")
|
||||||
@@ -246,78 +293,91 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
const drawioFormat =
|
||||||
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Each save has its own tag, so two at once never swap results
|
||||||
saveResolverRef.current = {
|
const tag = `save-${++exportSeqRef.current}`
|
||||||
resolver: (exportData: string) => {
|
exportResolversRef.current[tag] = (
|
||||||
let fileContent: string | Blob
|
exportData: string,
|
||||||
let mimeType: string
|
fullDiagramXML?: string,
|
||||||
let extension: string
|
) => {
|
||||||
|
delete exportResolversRef.current[tag]
|
||||||
|
let fileContent: string | Blob
|
||||||
|
let mimeType: string
|
||||||
|
let extension: string
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Extract XML from SVG for .drawio format
|
// Prefer the complete document from the export event so all pages are saved.
|
||||||
const xml = extractDiagramXML(exportData)
|
const xml = fullDiagramXML?.trim()
|
||||||
let xmlContent = xml
|
? fullDiagramXML
|
||||||
if (!xml.includes("<mxfile")) {
|
: extractDiagramXML(exportData)
|
||||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
fileContent =
|
||||||
}
|
normalizeToMxfile(xml, {
|
||||||
fileContent = xmlContent
|
pageId: "page-1",
|
||||||
mimeType = "application/xml"
|
pageName: "Page-1",
|
||||||
extension = ".drawio"
|
}) ?? xml
|
||||||
} else if (format === "png") {
|
mimeType = "application/xml"
|
||||||
// PNG data comes as base64 data URL
|
extension = ".drawio"
|
||||||
fileContent = exportData
|
} else if (format === "png") {
|
||||||
mimeType = "image/png"
|
// PNG data comes as base64 data URL
|
||||||
extension = ".png"
|
fileContent = exportData
|
||||||
} else {
|
mimeType = "image/png"
|
||||||
// SVG format
|
extension = ".png"
|
||||||
fileContent = exportData
|
} else if (format === "xmlsvg") {
|
||||||
mimeType = "image/svg+xml"
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
extension = ".svg"
|
fileContent = exportData
|
||||||
}
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".drawio.svg"
|
||||||
|
} else {
|
||||||
|
// SVG format (view-only)
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".svg"
|
||||||
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
// Log save event to Langfuse (flags the trace)
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
logSaveToLangfuse(filename, format, sessionId)
|
||||||
|
|
||||||
// Handle download
|
// Handle download
|
||||||
let url: string
|
let url: string
|
||||||
if (
|
if (
|
||||||
typeof fileContent === "string" &&
|
typeof fileContent === "string" &&
|
||||||
fileContent.startsWith("data:")
|
fileContent.startsWith("data:")
|
||||||
) {
|
) {
|
||||||
// Already a data URL (PNG)
|
// Already a data URL (PNG)
|
||||||
url = fileContent
|
url = fileContent
|
||||||
} else {
|
} else {
|
||||||
const blob = new Blob([fileContent], { type: mimeType })
|
const blob = new Blob([fileContent], { type: mimeType })
|
||||||
url = URL.createObjectURL(blob)
|
url = URL.createObjectURL(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${filename}${extension}`
|
a.download = `${filename}${extension}`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
// Show success toast after download is initiated
|
// Show success toast after download is initiated
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
toast.success(successMessage, {
|
toast.success(successMessage, {
|
||||||
position: "bottom-left",
|
position: "bottom-left",
|
||||||
duration: 2500,
|
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)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
format,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// Export diagram - callback will be handled in handleDiagramExport
|
||||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
drawioRef.current.exportDiagram({
|
||||||
|
format: drawioFormat,
|
||||||
|
message: tag,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||||
@@ -341,18 +401,21 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
setDiagramHistory,
|
setDiagramHistory,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
exportResolversRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
captureValidationPng,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
# Persists admin panel settings (data/settings.json)
|
||||||
|
- ./data:/app/data
|
||||||
# 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
|
||||||
|
|||||||
+37
-18
@@ -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)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
@@ -54,31 +56,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>动画Transformer连接器</strong><br />
|
<strong>动画Transformer连接器</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCP架构图</strong><br />
|
<strong>RAG技术图</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS架构图</strong><br />
|
<strong>React和AWS认证流程</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure架构图</strong><br />
|
<strong>开放式创新</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,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。
|
||||||
|
|
||||||
@@ -124,6 +124,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
|
|
||||||
图表会实时显示在浏览器中!
|
图表会实时显示在浏览器中!
|
||||||
|
|
||||||
|
MCP服务器包含网页版的大部分画图功能:
|
||||||
|
|
||||||
|
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
||||||
|
- 截图工具,AI可以查看画好的图并自行修正
|
||||||
|
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
||||||
|
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
||||||
|
|
||||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -195,14 +202,16 @@ 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
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -213,10 +222,20 @@ npm run dev
|
|||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||||
|
|
||||||
|
### 服务端多模型配置
|
||||||
|
|
||||||
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(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 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
|
|
||||||
|
### 管理面板
|
||||||
|
|
||||||
|
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
||||||
|
|
||||||
|
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
@@ -231,7 +250,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)来帮助我托管在线演示站点!
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 管理面板
|
||||||
|
|
||||||
|
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
|
||||||
|
|
||||||
|
## 启用面板
|
||||||
|
|
||||||
|
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
||||||
|
2. 访问 `/admin` 并登录。
|
||||||
|
|
||||||
|
## 可配置内容
|
||||||
|
|
||||||
|
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
||||||
|
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
||||||
|
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
||||||
|
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
||||||
|
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
||||||
+187
-3
@@ -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
|
||||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### AIHubMix
|
||||||
|
|
||||||
|
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
|
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
|
```
|
||||||
|
|
||||||
可选的自定义端点:
|
可选的自定义端点:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -127,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
|
||||||
|
|
||||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -207,6 +231,98 @@ 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-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
可选配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 中国大陆版,Anthropic 兼容(默认)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# 中国大陆版,OpenAI 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# 国际版,Anthropic 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# 国际版,OpenAI 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (智谱 AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (阿里云通义千问)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (月之暗面 Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (七牛云)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### MiMo (小米)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -214,9 +330,77 @@ 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, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 服务端多模型配置
|
||||||
|
|
||||||
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
|
||||||
|
|
||||||
|
### 配置方式
|
||||||
|
|
||||||
|
**方式一:环境变量**(推荐用于云部署)
|
||||||
|
|
||||||
|
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:配置文件**
|
||||||
|
|
||||||
|
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
||||||
|
|
||||||
|
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
|
||||||
|
|
||||||
|
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
|
||||||
|
|
||||||
|
### 配置示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "OpenAI Production",
|
||||||
|
"provider": "openai",
|
||||||
|
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Custom DeepSeek",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"models": ["deepseek-chat"],
|
||||||
|
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
||||||
|
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
|
||||||
|
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
||||||
|
| `models` | 是 | 模型 ID 列表 |
|
||||||
|
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
||||||
|
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
||||||
|
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
|
||||||
|
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"` 但 `apiKeyEnv` 不同)。
|
||||||
|
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
|
||||||
|
|
||||||
## 模型能力要求
|
## 模型能力要求
|
||||||
|
|
||||||
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Admin Panel
|
||||||
|
|
||||||
|
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
|
||||||
|
|
||||||
|
## Enabling the panel
|
||||||
|
|
||||||
|
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
||||||
|
2. Visit `/admin` and sign in.
|
||||||
|
|
||||||
|
## What you can configure
|
||||||
|
|
||||||
|
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||||
|
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
||||||
|
|
||||||
|
## Precedence
|
||||||
|
|
||||||
|
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
|
||||||
|
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
|
||||||
|
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
|
||||||
|
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
|
||||||
+202
-3
@@ -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
|
||||||
@@ -46,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### AIHubMix
|
||||||
|
|
||||||
|
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,6 +83,13 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
|
```
|
||||||
|
|
||||||
Optional custom endpoint:
|
Optional custom endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -127,9 +164,11 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
|
||||||
|
|
||||||
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -207,6 +246,98 @@ 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-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# China mainland, Anthropic-compatible (default)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# China mainland, OpenAI-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# International, Anthropic-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# International, OpenAI-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (Zhipu AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (Alibaba Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (Qiniu Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### MiMo (Xiaomi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## Auto-Detection
|
## 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`.
|
||||||
@@ -214,9 +345,77 @@ 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, modelscope
|
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Server-Side Multi-Model Configuration
|
||||||
|
|
||||||
|
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
|
||||||
|
|
||||||
|
### Configuration Methods
|
||||||
|
|
||||||
|
**Option 1: Environment Variable** (recommended for cloud deployments)
|
||||||
|
|
||||||
|
Set `AI_MODELS_CONFIG` as a JSON string:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Config File**
|
||||||
|
|
||||||
|
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
||||||
|
|
||||||
|
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
||||||
|
|
||||||
|
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
||||||
|
|
||||||
|
### Example Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "OpenAI Production",
|
||||||
|
"provider": "openai",
|
||||||
|
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Custom DeepSeek",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"models": ["deepseek-chat"],
|
||||||
|
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
||||||
|
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Reference
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `name` | Yes | Display name (supports multiple configs for same provider) |
|
||||||
|
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||||
|
| `models` | Yes | List of model IDs |
|
||||||
|
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||||
|
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
||||||
|
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
|
||||||
|
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
|
||||||
|
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
|
||||||
|
|
||||||
## Model Capability Requirements
|
## 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.
|
||||||
|
|||||||
+35
-18
@@ -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)
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCPアーキテクチャ図</strong><br />
|
<strong>RAG技術ダイアグラム</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWSアーキテクチャ図</strong><br />
|
<strong>ReactとAWSによる認証</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azureアーキテクチャ図</strong><br />
|
<strong>オープンイノベーション</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,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を使用できます。
|
||||||
|
|
||||||
@@ -124,6 +122,13 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||||
|
|
||||||
|
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
||||||
|
|
||||||
|
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
||||||
|
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
||||||
|
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
||||||
|
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
||||||
|
|
||||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
@@ -196,14 +201,16 @@ 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
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -214,10 +221,20 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||||
|
|
||||||
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(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ダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
|
### 管理パネル
|
||||||
|
|
||||||
|
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
||||||
|
|
||||||
|
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
|
|
||||||
@@ -232,7 +249,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)をご検討ください!
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 管理パネル
|
||||||
|
|
||||||
|
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
|
||||||
|
|
||||||
|
## パネルの有効化
|
||||||
|
|
||||||
|
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
||||||
|
2. `/admin` にアクセスしてサインインします。
|
||||||
|
|
||||||
|
## 設定できる項目
|
||||||
|
|
||||||
|
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
||||||
|
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
||||||
|
|
||||||
|
## 優先順位
|
||||||
|
|
||||||
|
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
|
||||||
|
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
|
||||||
|
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
|
||||||
|
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
|
||||||
+187
-3
@@ -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
|
||||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### AIHubMix
|
||||||
|
|
||||||
|
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
```
|
||||||
|
|
||||||
|
任意のカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
|
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||||
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
|
```
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
任意のカスタムエンドポイント:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -127,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
|
||||||
|
|
||||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -207,6 +231,98 @@ 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-M3
|
||||||
|
```
|
||||||
|
|
||||||
|
オプション設定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 中国大陸版、Anthropic 互換(デフォルト)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# 中国大陸版、OpenAI 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# 国際版、Anthropic 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# 国際版、OpenAI 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (Zhipu AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (Alibaba Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (Qiniu Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### MiMo (Xiaomi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_API_KEY=your_api_key
|
||||||
|
AI_MODEL=mimo-v2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -214,9 +330,77 @@ 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, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
|
||||||
|
|
||||||
|
### 設定方法
|
||||||
|
|
||||||
|
**方法1:環境変数**(クラウドデプロイ推奨)
|
||||||
|
|
||||||
|
`AI_MODELS_CONFIG` をJSON文字列として設定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法2:設定ファイル**
|
||||||
|
|
||||||
|
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
||||||
|
|
||||||
|
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
|
||||||
|
|
||||||
|
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AI_PROVIDER=doubao
|
||||||
|
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
|
||||||
|
|
||||||
|
### 設定例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "OpenAI Production",
|
||||||
|
"provider": "openai",
|
||||||
|
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Custom DeepSeek",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"models": ["deepseek-chat"],
|
||||||
|
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
||||||
|
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### フィールド説明
|
||||||
|
|
||||||
|
| フィールド | 必須 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
|
||||||
|
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
||||||
|
| `models` | はい | モデルIDのリスト |
|
||||||
|
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
||||||
|
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
||||||
|
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||||
|
|
||||||
|
### 備考
|
||||||
|
|
||||||
|
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名(例:`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
|
||||||
|
- `name` フィールドにより同一プロバイダーの複数設定が可能です(例:「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
|
||||||
|
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
|
||||||
|
|
||||||
## モデル性能要件
|
## モデル性能要件
|
||||||
|
|
||||||
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
||||||
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
||||||
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
||||||
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
|
|
||||||
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
||||||
|
|
||||||
## Networking & Infrastructure
|
## Networking & Infrastructure
|
||||||
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
||||||
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
||||||
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
|
|
||||||
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
||||||
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
||||||
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
||||||
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
||||||
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
|
|
||||||
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
||||||
|
|
||||||
## General Diagrams
|
## General Diagrams
|
||||||
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
||||||
|
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
|
||||||
|
|
||||||
## Enterprise Software
|
## Enterprise Software
|
||||||
|
|
||||||
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
||||||
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
|
|
||||||
|
|
||||||
**Total: 33 libraries, 4,281 shapes**
|
**Total: 30 libraries, 4,184 shapes**
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# material_design
|
||||||
|
|
||||||
|
**Type:** SVG images (Google Material Icons CDN)
|
||||||
|
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `{icon_name}` with any icon name from the list below.
|
||||||
|
|
||||||
|
## action (115)
|
||||||
|
|
||||||
|
- `account_balance`
|
||||||
|
- `account_balance_wallet`
|
||||||
|
- `account_box`
|
||||||
|
- `account_circle`
|
||||||
|
- `add_shopping_cart`
|
||||||
|
- `admin_panel_settings`
|
||||||
|
- `analytics`
|
||||||
|
- `arrow_right_alt`
|
||||||
|
- `article`
|
||||||
|
- `assessment`
|
||||||
|
- `assignment`
|
||||||
|
- `assignment_ind`
|
||||||
|
- `assignment_turned_in`
|
||||||
|
- `autorenew`
|
||||||
|
- `bookmark`
|
||||||
|
- `bookmark_border`
|
||||||
|
- `build`
|
||||||
|
- `calendar_month`
|
||||||
|
- `calendar_today`
|
||||||
|
- `card_giftcard`
|
||||||
|
- `check_circle`
|
||||||
|
- `check_circle_outline`
|
||||||
|
- `code`
|
||||||
|
- `contact_support`
|
||||||
|
- `credit_card`
|
||||||
|
- `dashboard`
|
||||||
|
- `date_range`
|
||||||
|
- `delete`
|
||||||
|
- `delete_forever`
|
||||||
|
- `delete_outline`
|
||||||
|
- `description`
|
||||||
|
- `dns`
|
||||||
|
- `done`
|
||||||
|
- `done_all`
|
||||||
|
- `done_outline`
|
||||||
|
- `drag_indicator`
|
||||||
|
- `event`
|
||||||
|
- `exit_to_app`
|
||||||
|
- `explore`
|
||||||
|
- `face`
|
||||||
|
- `fact_check`
|
||||||
|
- `favorite`
|
||||||
|
- `favorite_border`
|
||||||
|
- `feedback`
|
||||||
|
- `filter_alt`
|
||||||
|
- `fingerprint`
|
||||||
|
- `flight_takeoff`
|
||||||
|
- `grade`
|
||||||
|
- `help`
|
||||||
|
- `help_outline`
|
||||||
|
- `highlight_off`
|
||||||
|
- `history`
|
||||||
|
- `home`
|
||||||
|
- `info`
|
||||||
|
- `label`
|
||||||
|
- `language`
|
||||||
|
- `launch`
|
||||||
|
- `leaderboard`
|
||||||
|
- `lightbulb`
|
||||||
|
- `list`
|
||||||
|
- `lock`
|
||||||
|
- `lock_open`
|
||||||
|
- `login`
|
||||||
|
- `logout`
|
||||||
|
- `manage_accounts`
|
||||||
|
- `note_add`
|
||||||
|
- `open_in_full`
|
||||||
|
- `open_in_new`
|
||||||
|
- `paid`
|
||||||
|
- `payment`
|
||||||
|
- `pending`
|
||||||
|
- `pending_actions`
|
||||||
|
- `perm_identity`
|
||||||
|
- `pets`
|
||||||
|
- `power_settings_new`
|
||||||
|
- `preview`
|
||||||
|
- `print`
|
||||||
|
- `published_with_changes`
|
||||||
|
- `question_answer`
|
||||||
|
- `receipt`
|
||||||
|
- `reorder`
|
||||||
|
- `report_problem`
|
||||||
|
- `room`
|
||||||
|
- `savings`
|
||||||
|
- `schedule`
|
||||||
|
- `search`
|
||||||
|
- `settings`
|
||||||
|
- `shopping_bag`
|
||||||
|
- `shopping_basket`
|
||||||
|
- `shopping_cart`
|
||||||
|
- `star_rate`
|
||||||
|
- `stars`
|
||||||
|
- `store`
|
||||||
|
- `supervisor_account`
|
||||||
|
- `swap_horiz`
|
||||||
|
- `sync_alt`
|
||||||
|
- `task_alt`
|
||||||
|
- `thumb_up`
|
||||||
|
- `thumb_up_off_alt`
|
||||||
|
- `timeline`
|
||||||
|
- `tips_and_updates`
|
||||||
|
- `today`
|
||||||
|
- `touch_app`
|
||||||
|
- `trending_up`
|
||||||
|
- `update`
|
||||||
|
- `verified`
|
||||||
|
- `verified_user`
|
||||||
|
- `view_in_ar`
|
||||||
|
- `view_list`
|
||||||
|
- `visibility`
|
||||||
|
- `visibility_off`
|
||||||
|
- `watch_later`
|
||||||
|
- `work`
|
||||||
|
- `work_outline`
|
||||||
|
- `zoom_in`
|
||||||
|
|
||||||
|
## alert (4)
|
||||||
|
|
||||||
|
- `error`
|
||||||
|
- `error_outline`
|
||||||
|
- `warning`
|
||||||
|
- `warning_amber`
|
||||||
|
|
||||||
|
## av (12)
|
||||||
|
|
||||||
|
- `library_books`
|
||||||
|
- `mic`
|
||||||
|
- `pause`
|
||||||
|
- `play_arrow`
|
||||||
|
- `play_circle`
|
||||||
|
- `play_circle_filled`
|
||||||
|
- `play_circle_outline`
|
||||||
|
- `replay`
|
||||||
|
- `skip_next`
|
||||||
|
- `videocam`
|
||||||
|
- `volume_off`
|
||||||
|
- `volume_up`
|
||||||
|
|
||||||
|
## communication (13)
|
||||||
|
|
||||||
|
- `alternate_email`
|
||||||
|
- `business`
|
||||||
|
- `call`
|
||||||
|
- `chat`
|
||||||
|
- `chat_bubble_outline`
|
||||||
|
- `email`
|
||||||
|
- `forum`
|
||||||
|
- `list_alt`
|
||||||
|
- `location_on`
|
||||||
|
- `mail_outline`
|
||||||
|
- `phone`
|
||||||
|
- `qr_code_scanner`
|
||||||
|
- `vpn_key`
|
||||||
|
|
||||||
|
## content (27)
|
||||||
|
|
||||||
|
- `add`
|
||||||
|
- `add_box`
|
||||||
|
- `add_circle`
|
||||||
|
- `add_circle_outline`
|
||||||
|
- `block`
|
||||||
|
- `bolt`
|
||||||
|
- `calculate`
|
||||||
|
- `clear`
|
||||||
|
- `content_copy`
|
||||||
|
- `create`
|
||||||
|
- `filter_list`
|
||||||
|
- `flag`
|
||||||
|
- `how_to_reg`
|
||||||
|
- `insights`
|
||||||
|
- `inventory`
|
||||||
|
- `inventory_2`
|
||||||
|
- `link`
|
||||||
|
- `mail`
|
||||||
|
- `push_pin`
|
||||||
|
- `remove`
|
||||||
|
- `remove_circle`
|
||||||
|
- `remove_circle_outline`
|
||||||
|
- `reply`
|
||||||
|
- `save`
|
||||||
|
- `send`
|
||||||
|
- `sort`
|
||||||
|
- `undo`
|
||||||
|
|
||||||
|
## device (9)
|
||||||
|
|
||||||
|
- `dark_mode`
|
||||||
|
- `devices`
|
||||||
|
- `light_mode`
|
||||||
|
- `password`
|
||||||
|
- `restart_alt`
|
||||||
|
- `sell`
|
||||||
|
- `signal_cellular_alt`
|
||||||
|
- `summarize`
|
||||||
|
- `task`
|
||||||
|
|
||||||
|
## editor (9)
|
||||||
|
|
||||||
|
- `attach_file`
|
||||||
|
- `attach_money`
|
||||||
|
- `bar_chart`
|
||||||
|
- `checklist`
|
||||||
|
- `edit_note`
|
||||||
|
- `format_list_bulleted`
|
||||||
|
- `mode_edit`
|
||||||
|
- `monetization_on`
|
||||||
|
- `post_add`
|
||||||
|
|
||||||
|
## file (8)
|
||||||
|
|
||||||
|
- `cloud_upload`
|
||||||
|
- `download`
|
||||||
|
- `file_download`
|
||||||
|
- `file_upload`
|
||||||
|
- `folder`
|
||||||
|
- `folder_open`
|
||||||
|
- `grid_view`
|
||||||
|
- `upload_file`
|
||||||
|
|
||||||
|
## hardware (6)
|
||||||
|
|
||||||
|
- `computer`
|
||||||
|
- `keyboard_arrow_down`
|
||||||
|
- `keyboard_arrow_right`
|
||||||
|
- `phone_iphone`
|
||||||
|
- `security`
|
||||||
|
- `smartphone`
|
||||||
|
|
||||||
|
## image (16)
|
||||||
|
|
||||||
|
- `add_a_photo`
|
||||||
|
- `auto_awesome`
|
||||||
|
- `auto_stories`
|
||||||
|
- `circle`
|
||||||
|
- `collections`
|
||||||
|
- `edit`
|
||||||
|
- `image`
|
||||||
|
- `navigate_before`
|
||||||
|
- `navigate_next`
|
||||||
|
- `palette`
|
||||||
|
- `photo_camera`
|
||||||
|
- `picture_as_pdf`
|
||||||
|
- `receipt_long`
|
||||||
|
- `remove_red_eye`
|
||||||
|
- `timer`
|
||||||
|
- `tune`
|
||||||
|
|
||||||
|
## maps (11)
|
||||||
|
|
||||||
|
- `badge`
|
||||||
|
- `category`
|
||||||
|
- `directions_car`
|
||||||
|
- `local_fire_department`
|
||||||
|
- `local_offer`
|
||||||
|
- `local_shipping`
|
||||||
|
- `map`
|
||||||
|
- `menu_book`
|
||||||
|
- `place`
|
||||||
|
- `restaurant`
|
||||||
|
- `volunteer_activism`
|
||||||
|
|
||||||
|
## navigation (29)
|
||||||
|
|
||||||
|
- `apps`
|
||||||
|
- `arrow_back`
|
||||||
|
- `arrow_back_ios`
|
||||||
|
- `arrow_back_ios_new`
|
||||||
|
- `arrow_downward`
|
||||||
|
- `arrow_drop_down`
|
||||||
|
- `arrow_drop_up`
|
||||||
|
- `arrow_forward`
|
||||||
|
- `arrow_forward_ios`
|
||||||
|
- `arrow_right`
|
||||||
|
- `arrow_upward`
|
||||||
|
- `campaign`
|
||||||
|
- `cancel`
|
||||||
|
- `check`
|
||||||
|
- `chevron_left`
|
||||||
|
- `chevron_right`
|
||||||
|
- `close`
|
||||||
|
- `double_arrow`
|
||||||
|
- `east`
|
||||||
|
- `expand_less`
|
||||||
|
- `expand_more`
|
||||||
|
- `fullscreen`
|
||||||
|
- `menu`
|
||||||
|
- `menu_open`
|
||||||
|
- `more_horiz`
|
||||||
|
- `more_vert`
|
||||||
|
- `payments`
|
||||||
|
- `refresh`
|
||||||
|
- `unfold_more`
|
||||||
|
|
||||||
|
## notification (6)
|
||||||
|
|
||||||
|
- `account_tree`
|
||||||
|
- `event_available`
|
||||||
|
- `priority_high`
|
||||||
|
- `support_agent`
|
||||||
|
- `sync`
|
||||||
|
- `wifi`
|
||||||
|
|
||||||
|
## places (2)
|
||||||
|
|
||||||
|
- `apartment`
|
||||||
|
- `storefront`
|
||||||
|
|
||||||
|
## search (2)
|
||||||
|
|
||||||
|
- `feed`
|
||||||
|
- `manage_search`
|
||||||
|
|
||||||
|
## social (23)
|
||||||
|
|
||||||
|
- `construction`
|
||||||
|
- `emoji_emotions`
|
||||||
|
- `emoji_events`
|
||||||
|
- `engineering`
|
||||||
|
- `group`
|
||||||
|
- `group_add`
|
||||||
|
- `groups`
|
||||||
|
- `health_and_safety`
|
||||||
|
- `notifications`
|
||||||
|
- `notifications_active`
|
||||||
|
- `notifications_none`
|
||||||
|
- `people`
|
||||||
|
- `people_alt`
|
||||||
|
- `person`
|
||||||
|
- `person_add`
|
||||||
|
- `person_outline`
|
||||||
|
- `psychology`
|
||||||
|
- `public`
|
||||||
|
- `school`
|
||||||
|
- `share`
|
||||||
|
- `thumb_up_alt`
|
||||||
|
- `travel_explore`
|
||||||
|
- `water_drop`
|
||||||
|
|
||||||
|
## toggle (8)
|
||||||
|
|
||||||
|
- `check_box`
|
||||||
|
- `check_box_outline_blank`
|
||||||
|
- `radio_button_checked`
|
||||||
|
- `radio_button_unchecked`
|
||||||
|
- `star`
|
||||||
|
- `star_border`
|
||||||
|
- `star_outline`
|
||||||
|
- `toggle_on`
|
||||||
|
|
||||||
|
Total: 300 icons (top by popularity from 2100+ available)
|
||||||
@@ -67,41 +67,64 @@ const MODEL_ALIASES: Record<string, string> = {
|
|||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create standardized response with CORS headers
|
* Create standardized JSON response
|
||||||
*/
|
*/
|
||||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...CORS_HEADERS,
|
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
||||||
* Handle OPTIONS request for CORS preflight
|
// function, so no CORS headers are sent: other sites' pages can't call it
|
||||||
*/
|
// from a browser and spend the deployment's Edge AI quota.
|
||||||
function handleOptionsRequest(): Response {
|
// Same rule as lib/access-code.ts, but reading the edge function's env.
|
||||||
return new Response(null, {
|
// No codes configured (or env unavailable) means no check.
|
||||||
headers: {
|
function hasValidAccessCode(request: Request, env: any): boolean {
|
||||||
...CORS_HEADERS,
|
const accessCodes: string[] =
|
||||||
"Access-Control-Max-Age": "86400",
|
env?.ACCESS_CODE_LIST?.split(",")
|
||||||
},
|
.map((code: string) => code.trim())
|
||||||
})
|
.filter(Boolean) || []
|
||||||
|
if (accessCodes.length === 0) return true
|
||||||
|
const accessCode = request.headers.get("x-access-code")
|
||||||
|
return !!accessCode && accessCodes.includes(accessCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onRequest({ request, env: _env }: any) {
|
export async function onRequest({ request, env }: any) {
|
||||||
if (request.method === "OPTIONS") {
|
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||||
return handleOptionsRequest()
|
// preflight, which fails without CORS headers. Only the type before any
|
||||||
|
// parameters counts: "text/plain; x=application/json" needs none.
|
||||||
|
const mediaType = (request.headers.get("content-type") ?? "")
|
||||||
|
.split(";")[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (request.method !== "POST" || mediaType !== "application/json") {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Expected a POST request with a JSON body",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidAccessCode(request, env)) {
|
||||||
|
return createResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Invalid or missing access code",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.headers.delete("accept-encoding")
|
request.headers.delete("accept-encoding")
|
||||||
@@ -153,7 +176,7 @@ export async function onRequest({ request, env: _env }: any) {
|
|||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
429,
|
400,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +239,6 @@ export async function onRequest({ request, env: _env }: any) {
|
|||||||
"Cache-Control": "no-cache, no-store, no-transform",
|
"Cache-Control": "no-cache, no-store, no-transform",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
...CORS_HEADERS,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ directories:
|
|||||||
afterPack: ./scripts/afterPack.cjs
|
afterPack: ./scripts/afterPack.cjs
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- dist-electron/**/*
|
- from: dist-electron
|
||||||
- "!node_modules"
|
to: dist-electron
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
- from: .
|
||||||
|
filter:
|
||||||
|
- package.json
|
||||||
|
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
@@ -37,10 +42,11 @@ mac:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
hardenedRuntime: true
|
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
||||||
|
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
||||||
|
identity: null
|
||||||
|
hardenedRuntime: false
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
entitlements: resources/entitlements.mac.plist
|
|
||||||
entitlementsInherit: resources/entitlements.mac.plist
|
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents:
|
contents:
|
||||||
@@ -89,6 +95,10 @@ linux:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
|
- target: rpm
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
|
||||||
# Publish configuration (optional)
|
# Publish configuration (optional)
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
Vendored
+28
-5
@@ -38,6 +38,12 @@ interface SetProxyResult {
|
|||||||
devMode?: boolean
|
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 */
|
||||||
@@ -54,14 +60,25 @@ declare global {
|
|||||||
maximize: () => void
|
maximize: () => void
|
||||||
/** Close the window */
|
/** Close the window */
|
||||||
close: () => void
|
close: () => void
|
||||||
/** Open file dialog and return file path */
|
|
||||||
openFile: () => Promise<string | null>
|
|
||||||
/** Save data to file via save dialog */
|
|
||||||
saveFile: (data: string) => Promise<boolean>
|
|
||||||
/** Get proxy configuration */
|
/** Get proxy configuration */
|
||||||
getProxy: () => Promise<ProxyConfig>
|
getProxy: () => Promise<ProxyConfig>
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||||
|
/** Get user's preferred locale */
|
||||||
|
getUserLocale: () => Promise<
|
||||||
|
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
||||||
|
>
|
||||||
|
/** Set user's preferred locale */
|
||||||
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
|
/**
|
||||||
|
* Call back after the server restarted on the same port (another
|
||||||
|
* preset); returns a function that stops the calls
|
||||||
|
*/
|
||||||
|
onServerRestarted?: (callback: () => void) => () => void
|
||||||
|
/** A chat was saved: open this port next launch */
|
||||||
|
chatSaved?: () => Promise<void>
|
||||||
|
/** The page loaded with this many chats */
|
||||||
|
chatsLoaded?: (count: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -88,4 +105,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ConfigPreset, ApplyPresetResult, ProxyConfig, SetProxyResult }
|
export type {
|
||||||
|
ApplyPresetResult,
|
||||||
|
ConfigPreset,
|
||||||
|
ProxyConfig,
|
||||||
|
SetProxyResult,
|
||||||
|
SetUserLocaleResult,
|
||||||
|
}
|
||||||
|
|||||||
+123
-72
@@ -12,11 +12,12 @@ import {
|
|||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
|
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { showSettingsWindow } from "./settings-window"
|
import { showSettingsWindow } from "./settings-window"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu
|
* Build and set the application menu with i18n support
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -25,18 +26,84 @@ export function buildAppMenu(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the menu (call this when presets change)
|
* Rebuild the menu (call this when presets change or language changes)
|
||||||
*/
|
*/
|
||||||
export function rebuildAppMenu(): void {
|
export function rebuildAppMenu(): void {
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Number of the latest preset switch
|
||||||
|
let lastSwitch = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template
|
* Apply a preset and restart the server so it takes effect.
|
||||||
|
* If the restart fails, go back to the previous preset and restart again,
|
||||||
|
* so the running server always matches the saved current preset.
|
||||||
|
* Throws an error describing the outcome on failure.
|
||||||
|
*/
|
||||||
|
export async function switchPreset(
|
||||||
|
id: string,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const previousPresetId = getCurrentPresetId()
|
||||||
|
const env = applyPresetToEnv(id)
|
||||||
|
if (!env) {
|
||||||
|
throw new Error("Preset not found")
|
||||||
|
}
|
||||||
|
const switchNumber = ++lastSwitch
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartNextServer()
|
||||||
|
return env
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restart server:", error)
|
||||||
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
// A newer switch started meanwhile (also of this same preset), or
|
||||||
|
// the preset was deleted: its own restart follows, and undoing
|
||||||
|
// would lose that choice
|
||||||
|
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert to previous preset on failure
|
||||||
|
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||||
|
setCurrentPreset(null)
|
||||||
|
}
|
||||||
|
// Rebuild menu to restore previous checkmark state
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartNextServer()
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error("Failed to restart server again:", retryError)
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the menu template with translations
|
||||||
*/
|
*/
|
||||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
// Get translations for preferred locale (saved preference or system default)
|
||||||
|
const locale = getPreferredLocale(app.getLocale())
|
||||||
|
const t = getMenuTranslations(locale)
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = []
|
const template: MenuItemConstructorOptions[] = []
|
||||||
|
|
||||||
// macOS app menu
|
// macOS app menu
|
||||||
@@ -44,10 +111,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Settings...",
|
label: t.settings,
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -55,26 +122,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "services" },
|
{ role: "services" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" }, // System-translated
|
||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" }, // System-translated
|
||||||
{ role: "unhide" },
|
{ role: "unhide" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{ role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "File",
|
label: t.file,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
...(isMac
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: t.settings,
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -83,76 +150,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
isMac ? { role: "close" } : { role: "quit" },
|
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Edit",
|
label: t.edit,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" },
|
{ role: "undo" }, // System-translated
|
||||||
{ role: "redo" },
|
{ role: "redo" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "cut" },
|
{ role: "cut" }, // System-translated
|
||||||
{ role: "copy" },
|
{ role: "copy" }, // System-translated
|
||||||
{ role: "paste" },
|
{ role: "paste" }, // System-translated
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
role: "pasteAndMatchStyle",
|
role: "pasteAndMatchStyle",
|
||||||
} as MenuItemConstructorOptions,
|
} as MenuItemConstructorOptions, // System-translated
|
||||||
{ role: "delete" } as MenuItemConstructorOptions,
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ role: "delete" } as MenuItemConstructorOptions,
|
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "View",
|
label: t.view,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" },
|
{ role: "reload" }, // System-translated
|
||||||
{ role: "forceReload" },
|
{ role: "forceReload" }, // System-translated
|
||||||
{ role: "toggleDevTools" },
|
{ role: "toggleDevTools" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "resetZoom" },
|
{ role: "resetZoom" }, // System-translated
|
||||||
{ role: "zoomIn" },
|
{ role: "zoomIn" }, // System-translated
|
||||||
{ role: "zoomOut" },
|
{ role: "zoomOut" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" },
|
{ role: "togglefullscreen" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
// Configuration menu with presets
|
||||||
template.push(buildConfigMenu())
|
template.push(buildConfigMenu(t))
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Window",
|
label: t.window,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "minimize" },
|
{ role: "minimize" }, // System-translated
|
||||||
{ role: "zoom" },
|
{ role: "zoom" }, // System-translated
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "front" } as MenuItemConstructorOptions,
|
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
||||||
]
|
]
|
||||||
: [{ role: "close" } as MenuItemConstructorOptions]),
|
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Help",
|
label: t.help,
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: "Documentation",
|
label: t.documentation,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||||
@@ -160,7 +227,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Report Issue",
|
label: t.reportIssue,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||||
@@ -176,7 +243,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
/**
|
/**
|
||||||
* Build the Configuration menu with presets
|
* Build the Configuration menu with presets
|
||||||
*/
|
*/
|
||||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
function buildConfigMenu(
|
||||||
|
t: ReturnType<typeof getMenuTranslations>,
|
||||||
|
): MenuItemConstructorOptions {
|
||||||
const presets = getAllPresets()
|
const presets = getAllPresets()
|
||||||
const currentPresetId = getCurrentPresetId()
|
const currentPresetId = getCurrentPresetId()
|
||||||
|
|
||||||
@@ -185,42 +254,24 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
|||||||
type: "radio",
|
type: "radio",
|
||||||
checked: preset.id === currentPresetId,
|
checked: preset.id === currentPresetId,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const previousPresetId = getCurrentPresetId()
|
try {
|
||||||
const env = applyPresetToEnv(preset.id)
|
await switchPreset(preset.id)
|
||||||
|
} catch (error) {
|
||||||
if (env) {
|
// Show error dialog to notify user
|
||||||
try {
|
dialog.showErrorBox(
|
||||||
await restartNextServer()
|
"Configuration Error",
|
||||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||||
} catch (error) {
|
)
|
||||||
console.error("Failed to restart server:", error)
|
|
||||||
|
|
||||||
// Revert to previous preset on failure
|
|
||||||
if (previousPresetId) {
|
|
||||||
applyPresetToEnv(previousPresetId)
|
|
||||||
} else {
|
|
||||||
setCurrentPreset(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild menu to restore previous checkmark state
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// Show error dialog to notify user
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Configuration Error",
|
|
||||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Configuration",
|
label: t.configuration,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(presetItems.length > 0
|
...(presetItems.length > 0
|
||||||
? [
|
? [
|
||||||
{ label: "Switch Preset", enabled: false },
|
{ label: t.switchPreset, enabled: false },
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
...presetItems,
|
...presetItems,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
@@ -229,8 +280,8 @@ function buildConfigMenu(): MenuItemConstructorOptions {
|
|||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
presetItems.length > 0
|
presetItems.length > 0
|
||||||
? "Manage Presets..."
|
? t.managePresets
|
||||||
: "Add Configuration Preset...",
|
: t.addConfigurationPreset,
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
showSettingsWindow(win || undefined)
|
showSettingsWindow(win || undefined)
|
||||||
|
|||||||
+178
-72
@@ -1,5 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { app, safeStorage } from "electron"
|
import { app, safeStorage } from "electron"
|
||||||
|
|
||||||
@@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false
|
|||||||
* Warns if encryption is not available (API key stored in plaintext)
|
* Warns if encryption is not available (API key stored in plaintext)
|
||||||
*/
|
*/
|
||||||
function encryptValue(value: string): string {
|
function encryptValue(value: string): string {
|
||||||
if (!value) {
|
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||||
|
// instead of wrapping it in a second layer of encryption
|
||||||
|
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ function encryptValue(value: string): string {
|
|||||||
/**
|
/**
|
||||||
* Decrypt a sensitive value using safeStorage
|
* Decrypt a sensitive value using safeStorage
|
||||||
* Returns the original value if it's not encrypted or decryption fails
|
* Returns the original value if it's not encrypted or decryption fails
|
||||||
|
* (so saving writes the stored ciphertext back unchanged)
|
||||||
*/
|
*/
|
||||||
function decryptValue(value: string): string {
|
function decryptValue(value: string): string {
|
||||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
@@ -137,6 +146,7 @@ interface ConfigPresetsFile {
|
|||||||
version: 1
|
version: 1
|
||||||
currentPresetId: string | null
|
currentPresetId: string | null
|
||||||
presets: ConfigPreset[]
|
presets: ConfigPreset[]
|
||||||
|
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
const CONFIG_FILE_NAME = "config-presets.json"
|
||||||
@@ -149,6 +159,10 @@ function getConfigFilePath(): string {
|
|||||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The presets file exists but the last read failed: a save now would
|
||||||
|
// replace the user's presets with the empty list that read returned
|
||||||
|
let presetsUnreadable = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load presets from the config file
|
* Load presets from the config file
|
||||||
* Decrypts sensitive fields automatically
|
* Decrypts sensitive fields automatically
|
||||||
@@ -157,15 +171,34 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
|
// Nothing left that a save could overwrite
|
||||||
|
presetsUnreadable = false
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = readFileSync(configPath, "utf-8")
|
||||||
|
presetsUnreadable = false
|
||||||
|
} catch (error) {
|
||||||
|
// Often only for now (on Windows an antivirus scanner can hold the
|
||||||
|
// file): keep the file, and refuse saves based on this empty list
|
||||||
|
console.error("Failed to read config presets:", error)
|
||||||
|
presetsUnreadable = true
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
currentPresetId: null,
|
||||||
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(configPath, "utf-8")
|
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
const data = JSON.parse(content) as ConfigPresetsFile
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
// Decrypt sensitive fields in each preset
|
||||||
@@ -177,10 +210,22 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config presets:", error)
|
console.error("Failed to load config presets:", error)
|
||||||
|
// Move the unreadable file aside so the next save can't overwrite
|
||||||
|
// the user's presets with an empty list
|
||||||
|
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
||||||
|
try {
|
||||||
|
renameSync(configPath, backupPath)
|
||||||
|
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||||
|
} catch (renameError) {
|
||||||
|
// Still there: refuse saves that would overwrite it
|
||||||
|
console.error("Failed to back up config presets:", renameError)
|
||||||
|
presetsUnreadable = true
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
|
userLocale: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +235,11 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
* Encrypts sensitive fields automatically
|
* Encrypts sensitive fields automatically
|
||||||
*/
|
*/
|
||||||
export function savePresets(data: ConfigPresetsFile): void {
|
export function savePresets(data: ConfigPresetsFile): void {
|
||||||
|
if (presetsUnreadable) {
|
||||||
|
throw new Error(
|
||||||
|
"The presets file could not be read, so it was not overwritten. Please try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
const userDataPath = app.getPath("userData")
|
const userDataPath = app.getPath("userData")
|
||||||
|
|
||||||
@@ -208,7 +258,11 @@ export function savePresets(data: ConfigPresetsFile): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
|
// a truncated config file
|
||||||
|
const tempPath = `${configPath}.tmp`
|
||||||
|
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||||
|
renameSync(tempPath, configPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save config presets:", error)
|
console.error("Failed to save config presets:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -304,9 +358,10 @@ export function deletePreset(id: string): boolean {
|
|||||||
|
|
||||||
data.presets.splice(index, 1)
|
data.presets.splice(index, 1)
|
||||||
|
|
||||||
// Clear current preset if it was deleted
|
// Clear current preset (and its env vars) if it was deleted
|
||||||
if (data.currentPresetId === id) {
|
if (data.currentPresetId === id) {
|
||||||
data.currentPresetId = null
|
data.currentPresetId = null
|
||||||
|
setPresetEnv(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
@@ -319,13 +374,15 @@ export function deletePreset(id: string): boolean {
|
|||||||
export function setCurrentPreset(id: string | null): boolean {
|
export function setCurrentPreset(id: string | null): boolean {
|
||||||
const data = loadPresets()
|
const data = loadPresets()
|
||||||
|
|
||||||
|
let preset: ConfigPreset | null = null
|
||||||
if (id !== null) {
|
if (id !== null) {
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
preset = data.presets.find((p) => p.id === id) || null
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPresetEnv(preset)
|
||||||
data.currentPresetId = id
|
data.currentPresetId = id
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
return true
|
return true
|
||||||
@@ -356,84 +413,29 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
baseUrl: "MODELSCOPE_BASE_URL",
|
||||||
},
|
},
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock and ollama don't use API keys in the same way
|
// bedrock doesn't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply preset environment variables to the current process
|
* Map a preset's config to environment variables
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
|
||||||
|
|
||||||
if (!preset) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEnv: Record<string, string> = {}
|
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
|
||||||
if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
process.env[providerApiKey] = value
|
|
||||||
appliedEnv[providerApiKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Map generic AI_BASE_URL to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_BASE_URL" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
||||||
if (providerBaseUrl) {
|
|
||||||
process.env[providerBaseUrl] = value
|
|
||||||
appliedEnv[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
process.env[key] = value
|
|
||||||
appliedEnv[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set as current preset
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return appliedEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get environment variables from current preset
|
|
||||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||||
*/
|
*/
|
||||||
export function getCurrentPresetEnv(): Record<string, string> {
|
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||||
const preset = getCurrentPreset()
|
|
||||||
if (!preset) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {}
|
const env: Record<string, string> = {}
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
for (const [key, value] of Object.entries(preset.config)) {
|
||||||
if (value !== undefined && value !== "") {
|
if (value !== undefined && value !== "") {
|
||||||
|
// A key that could not be decrypted is useless to the server
|
||||||
|
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
console.warn(
|
||||||
|
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
// Map generic AI_API_KEY to provider-specific key
|
||||||
if (
|
else if (
|
||||||
key === "AI_API_KEY" &&
|
key === "AI_API_KEY" &&
|
||||||
provider &&
|
provider &&
|
||||||
PROVIDER_ENV_MAP[provider]
|
PROVIDER_ENV_MAP[provider]
|
||||||
@@ -462,3 +464,107 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values that env vars had before a preset first set them
|
||||||
|
* (from the system or .env files), and the keys the active preset set
|
||||||
|
*/
|
||||||
|
const originalEnv: Record<string, string | undefined> = {}
|
||||||
|
let presetEnvKeys: string[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the env vars of the previous preset with those of the given preset
|
||||||
|
* (null leaves no preset applied). Restoring first means switching presets
|
||||||
|
* never leaves the previous preset's base URL, model or key behind.
|
||||||
|
*/
|
||||||
|
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||||
|
for (const key of presetEnvKeys) {
|
||||||
|
if (originalEnv[key] === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = originalEnv[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = preset ? presetToEnv(preset) : {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
originalEnv[key] = process.env[key]
|
||||||
|
}
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
presetEnvKeys = Object.keys(env)
|
||||||
|
|
||||||
|
writeDevPresetEnv(env)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||||
|
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||||
|
* this file changes. The dev server can't decrypt the config file itself.
|
||||||
|
*/
|
||||||
|
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||||
|
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||||
|
encoding: "utf-8",
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to write dev preset env:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preset environment variables to the current process
|
||||||
|
* Returns the environment variables that were applied
|
||||||
|
*/
|
||||||
|
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||||
|
const data = loadPresets()
|
||||||
|
const preset = data.presets.find((p) => p.id === id)
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedEnv = setPresetEnv(preset)
|
||||||
|
|
||||||
|
// Set as current preset
|
||||||
|
data.currentPresetId = id
|
||||||
|
savePresets(data)
|
||||||
|
|
||||||
|
return appliedEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the saved current preset's environment variables (used at startup)
|
||||||
|
*/
|
||||||
|
export function applyCurrentPresetToEnv(): void {
|
||||||
|
setPresetEnv(getCurrentPreset())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's preferred locale from config
|
||||||
|
* Returns undefined if not set
|
||||||
|
*/
|
||||||
|
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
||||||
|
const data = loadPresets()
|
||||||
|
return data.userLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user's preferred locale in config
|
||||||
|
*/
|
||||||
|
export function setUserLocale(
|
||||||
|
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
||||||
|
): void {
|
||||||
|
const data = loadPresets()
|
||||||
|
data.userLocale = locale === null ? undefined : locale
|
||||||
|
savePresets(data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ export function loadEnvFile(): void {
|
|||||||
console.log("No .env file found, using system environment variables")
|
console.log("No .env file found, using system environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the quote that closes a value starting with a quote, or -1. A
|
||||||
|
* backslash before the quote character escapes it, as in dotenv; the
|
||||||
|
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||||
|
* comment or nothing after it still closes the value when no other quote
|
||||||
|
* does ("C:\dir\" keeps its trailing backslash).
|
||||||
|
*/
|
||||||
|
function findClosingQuote(value: string): number {
|
||||||
|
const quote = value[0]
|
||||||
|
let lastEscaped = -1
|
||||||
|
for (let i = 1; i < value.length; i++) {
|
||||||
|
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||||
|
i++
|
||||||
|
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||||
|
} else if (value[i] === quote) return i
|
||||||
|
}
|
||||||
|
return lastEscaped
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and load environment variables from a file
|
* Parse and load environment variables from a file
|
||||||
*/
|
*/
|
||||||
@@ -48,12 +67,28 @@ function loadEnvFromFile(filePath: string): void {
|
|||||||
const key = trimmed.slice(0, equalIndex).trim()
|
const key = trimmed.slice(0, equalIndex).trim()
|
||||||
let value = trimmed.slice(equalIndex + 1).trim()
|
let value = trimmed.slice(equalIndex + 1).trim()
|
||||||
|
|
||||||
// Remove surrounding quotes
|
const quote = value[0]
|
||||||
|
const closingQuote =
|
||||||
|
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||||
if (
|
if (
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
closingQuote > 0 &&
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||||
) {
|
) {
|
||||||
value = value.slice(1, -1)
|
// Quoted value, then nothing or a comment: keep what is
|
||||||
|
// inside the quotes, as dotenv reads it
|
||||||
|
value = value.slice(1, closingQuote)
|
||||||
|
} else {
|
||||||
|
// Unquoted value: drop an inline comment ("value # comment").
|
||||||
|
// A value quoted from start to end with quotes inside (JSON
|
||||||
|
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||||
|
value = value.replace(/\s+#.*$/, "")
|
||||||
|
if (
|
||||||
|
closingQuote > 0 &&
|
||||||
|
value.length > 1 &&
|
||||||
|
value.endsWith(quote)
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't override existing environment variables
|
// Don't override existing environment variables
|
||||||
|
|||||||
+50
-19
@@ -1,12 +1,17 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { getCurrentPresetEnv } from "./config-manager"
|
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
import { applyProxyToEnv } from "./proxy-manager"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import {
|
||||||
|
createWindow,
|
||||||
|
getAppUrl,
|
||||||
|
getMainWindow,
|
||||||
|
isAppUrl,
|
||||||
|
} from "./window-manager"
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
@@ -28,16 +33,14 @@ if (!gotTheLock) {
|
|||||||
// Apply proxy settings from saved config
|
// Apply proxy settings from saved config
|
||||||
applyProxyToEnv()
|
applyProxyToEnv()
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
const isDev = !app.isPackaged
|
||||||
const presetEnv = getCurrentPresetEnv()
|
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
let serverUrl: string | null = null
|
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
// Apply saved preset environment variables (overrides .env).
|
||||||
|
// Must run after ready: on Windows and Linux safeStorage can't
|
||||||
|
// decrypt the API key before that.
|
||||||
|
applyCurrentPresetToEnv()
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
registerSettingsWindowHandlers()
|
||||||
@@ -46,6 +49,7 @@ if (!gotTheLock) {
|
|||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let serverUrl: string
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Development: use the dev server URL
|
// Development: use the dev server URL
|
||||||
serverUrl =
|
serverUrl =
|
||||||
@@ -69,8 +73,9 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
if (serverUrl) {
|
const appUrl = getAppUrl()
|
||||||
createWindow(serverUrl)
|
if (appUrl) {
|
||||||
|
createWindow(appUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -87,23 +92,49 @@ if (!gotTheLock) {
|
|||||||
stopNextServer()
|
stopNextServer()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pages allowed inside app windows: the app server and draw.io
|
||||||
|
const isInAppUrl = (url: string): boolean => {
|
||||||
|
if (isAppUrl(url)) return true
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(url)
|
||||||
|
return ["diagrams.net", "draw.io"].some(
|
||||||
|
(domain) =>
|
||||||
|
hostname === domain || hostname.endsWith(`.${domain}`),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWebUrl = (url: string): boolean =>
|
||||||
|
url.startsWith("http://") || url.startsWith("https://")
|
||||||
|
|
||||||
// Open external links in default browser
|
// Open external links in default browser
|
||||||
app.on("web-contents-created", (_, contents) => {
|
app.on("web-contents-created", (_, contents) => {
|
||||||
contents.setWindowOpenHandler(({ url }) => {
|
contents.setWindowOpenHandler(({ url }) => {
|
||||||
// Allow diagrams.net iframe
|
if (isInAppUrl(url)) {
|
||||||
if (
|
|
||||||
url.includes("diagrams.net") ||
|
|
||||||
url.includes("draw.io") ||
|
|
||||||
url.startsWith("http://localhost")
|
|
||||||
) {
|
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
// Open other links in external browser
|
// Open other links in external browser
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (isWebUrl(url)) {
|
||||||
shell.openExternal(url)
|
shell.openExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clicking a plain link would otherwise replace the app page with
|
||||||
|
// an external site that keeps the preload API. Only the page
|
||||||
|
// itself may navigate there; draw.io stays in its frame (this event
|
||||||
|
// is for the main frame only)
|
||||||
|
contents.on("will-navigate", (event) => {
|
||||||
|
if (isAppUrl(event.url)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
if (isWebUrl(event.url)) {
|
||||||
|
shell.openExternal(event.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-97
@@ -1,22 +1,26 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||||
|
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
createPreset,
|
createPreset,
|
||||||
deletePreset,
|
deletePreset,
|
||||||
getAllPresets,
|
getAllPresets,
|
||||||
getCurrentPreset,
|
getCurrentPreset,
|
||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
|
getUserLocale,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
|
setUserLocale,
|
||||||
updatePreset,
|
updatePreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
|
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||||
import {
|
import {
|
||||||
applyProxyToEnv,
|
applyProxyToEnv,
|
||||||
getProxyConfig,
|
getProxyConfig,
|
||||||
type ProxyConfig,
|
type ProxyConfig,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
} from "./proxy-manager"
|
} from "./proxy-manager"
|
||||||
|
import { isAppUrl } from "./window-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -45,16 +49,44 @@ function sanitizePresetConfig(
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an IPC handler that only answers the app's own pages
|
||||||
|
* (the main window on the app server, or the local settings page).
|
||||||
|
* A main window that somehow ends up on an external site still gets the
|
||||||
|
* preload API, so its calls must be rejected here.
|
||||||
|
*/
|
||||||
|
function handle<Args extends unknown[]>(
|
||||||
|
channel: string,
|
||||||
|
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(channel, (event, ...args) => {
|
||||||
|
const url = event.senderFrame?.url
|
||||||
|
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||||
|
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||||
|
}
|
||||||
|
return listener(event, ...(args as Args))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
*/
|
*/
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ==================== App Info ====================
|
// ==================== App Info ====================
|
||||||
|
|
||||||
ipcMain.handle("get-version", () => {
|
handle("get-version", () => {
|
||||||
return app.getVersion()
|
return app.getVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== Where the chats are ====================
|
||||||
|
|
||||||
|
// The page saved a chat, or loaded without any: decides which port
|
||||||
|
// (and so which origin's chats) the next launch opens
|
||||||
|
handle("chat-saved", () => rememberChatPort())
|
||||||
|
handle("chats-loaded", (_event, count: unknown) => {
|
||||||
|
if (count === 0) noteNoChats()
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== Window Controls ====================
|
// ==================== Window Controls ====================
|
||||||
|
|
||||||
ipcMain.on("window-minimize", (event) => {
|
ipcMain.on("window-minimize", (event) => {
|
||||||
@@ -76,84 +108,30 @@ export function registerIpcHandlers(): void {
|
|||||||
win?.close()
|
win?.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== File Dialogs ====================
|
|
||||||
|
|
||||||
ipcMain.handle("dialog-open-file", async (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (!win) return null
|
|
||||||
|
|
||||||
const result = await dialog.showOpenDialog(win, {
|
|
||||||
properties: ["openFile"],
|
|
||||||
filters: [
|
|
||||||
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
|
||||||
{ name: "All Files", extensions: ["*"] },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file content
|
|
||||||
const fs = await import("node:fs/promises")
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
|
||||||
return content
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to read file:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (!win) return false
|
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog(win, {
|
|
||||||
filters: [
|
|
||||||
{ name: "Draw.io Files", extensions: ["drawio"] },
|
|
||||||
{ name: "XML Files", extensions: ["xml"] },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fs = await import("node:fs/promises")
|
|
||||||
try {
|
|
||||||
await fs.writeFile(result.filePath, data, "utf-8")
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save file:", error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
// ==================== Config Presets ====================
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-all", () => {
|
handle("config-presets:get-all", () => {
|
||||||
return getAllPresets()
|
return getAllPresets()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current", () => {
|
handle("config-presets:get-current", () => {
|
||||||
return getCurrentPreset()
|
return getCurrentPreset()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current-id", () => {
|
handle("config-presets:get-current-id", () => {
|
||||||
return getCurrentPresetId()
|
return getCurrentPresetId()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
handle(
|
||||||
"config-presets:save",
|
"config-presets:save",
|
||||||
(
|
async (
|
||||||
_event,
|
_event,
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||||
id?: string
|
id?: string
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// Validate preset name
|
// Validate preset name
|
||||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
||||||
throw new Error("Invalid preset name")
|
throw new Error("Invalid preset name")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,42 +140,48 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
if (preset.id) {
|
if (preset.id) {
|
||||||
// Update existing preset
|
// Update existing preset
|
||||||
return updatePreset(preset.id, {
|
const updated = updatePreset(preset.id, {
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
|
// Re-apply the active preset so the edit takes effect
|
||||||
|
if (updated && updated.id === getCurrentPresetId()) {
|
||||||
|
await switchPreset(updated.id)
|
||||||
|
} else {
|
||||||
|
rebuildAppMenu()
|
||||||
|
}
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
// Create new preset
|
// Create new preset
|
||||||
return createPreset({
|
const created = createPreset({
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
|
rebuildAppMenu()
|
||||||
|
return created
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
handle("config-presets:delete", async (_event, id: string) => {
|
||||||
return deletePreset(id)
|
const wasCurrent = id === getCurrentPresetId()
|
||||||
|
// Deleting the active preset also clears its env vars
|
||||||
|
const deleted = deletePreset(id)
|
||||||
|
rebuildAppMenu()
|
||||||
|
|
||||||
|
// Restart so the server stops using the deleted preset
|
||||||
|
if (deleted && wasCurrent && app.isPackaged) {
|
||||||
|
await restartNextServer()
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
|
handle("config-presets:apply", async (_event, id: string) => {
|
||||||
const env = applyPresetToEnv(id)
|
|
||||||
if (!env) {
|
|
||||||
return { success: false, error: "Preset not found" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development mode, the config file change will trigger
|
|
||||||
// the file watcher in electron-dev.mjs to restart Next.js
|
|
||||||
// We just need to save the preset (already done in applyPresetToEnv)
|
|
||||||
return { success: true, env, devMode: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production mode: restart the Next.js server to apply new environment variables
|
|
||||||
try {
|
try {
|
||||||
await restartNextServer()
|
const env = await switchPreset(id)
|
||||||
return { success: true, env }
|
// In development mode, electron-dev.mjs restarts Next.js
|
||||||
|
return app.isPackaged
|
||||||
|
? { success: true, env }
|
||||||
|
: { success: true, env, devMode: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -209,30 +193,39 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||||
"config-presets:set-current",
|
return setCurrentPreset(id)
|
||||||
(_event, id: string | null) => {
|
})
|
||||||
return setCurrentPreset(id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
// ==================== Proxy Settings ====================
|
||||||
|
|
||||||
ipcMain.handle("get-proxy", () => {
|
handle("get-proxy", () => {
|
||||||
return getProxyConfig()
|
return getProxyConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||||
|
const isOptionalString = (value: unknown) =>
|
||||||
|
value === undefined || typeof value === "string"
|
||||||
|
if (
|
||||||
|
typeof config !== "object" ||
|
||||||
|
config === null ||
|
||||||
|
!isOptionalString(config.httpProxy) ||
|
||||||
|
!isOptionalString(config.httpsProxy)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid proxy settings" }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save config to file
|
// Save config to file
|
||||||
saveProxyConfig(config)
|
saveProxyConfig({
|
||||||
|
httpProxy: config.httpProxy,
|
||||||
|
httpsProxy: config.httpsProxy,
|
||||||
|
})
|
||||||
|
|
||||||
// Apply to current process environment
|
// Apply to current process environment
|
||||||
applyProxyToEnv()
|
applyProxyToEnv()
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
if (!app.isPackaged) {
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development, env vars are already applied
|
// In development, env vars are already applied
|
||||||
// Next.js dev server may need manual restart
|
// Next.js dev server may need manual restart
|
||||||
return { success: true, devMode: true }
|
return { success: true, devMode: true }
|
||||||
@@ -251,4 +244,32 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== User Locale ====================
|
||||||
|
|
||||||
|
handle("get-user-locale", () => {
|
||||||
|
return getUserLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle("set-user-locale", (_event, locale: string) => {
|
||||||
|
// Validate locale is one of the supported values
|
||||||
|
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||||
|
return { success: false, error: "Invalid locale" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||||
|
// Rebuild the menu to reflect the new locale
|
||||||
|
rebuildAppMenu()
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to set locale",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Internationalization support for Electron menu
|
||||||
|
* Translations for menu labels that don't use Electron's built-in roles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getUserLocale } from "./config-manager"
|
||||||
|
|
||||||
|
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
||||||
|
|
||||||
|
export interface MenuTranslations {
|
||||||
|
// App menu (macOS only)
|
||||||
|
settings: string
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: string
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: string
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: string
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: string
|
||||||
|
switchPreset: string
|
||||||
|
managePresets: string
|
||||||
|
addConfigurationPreset: string
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: string
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: string
|
||||||
|
documentation: string
|
||||||
|
reportIssue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||||
|
en: {
|
||||||
|
// App menu
|
||||||
|
settings: "Settings...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "File",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "Edit",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "View",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "Configuration",
|
||||||
|
switchPreset: "Switch Preset",
|
||||||
|
managePresets: "Manage Presets...",
|
||||||
|
addConfigurationPreset: "Add Configuration Preset...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "Window",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "Help",
|
||||||
|
documentation: "Documentation",
|
||||||
|
reportIssue: "Report Issue",
|
||||||
|
},
|
||||||
|
|
||||||
|
zh: {
|
||||||
|
// App menu
|
||||||
|
settings: "设置...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "文件",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "编辑",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "查看",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切换预设",
|
||||||
|
managePresets: "管理预设...",
|
||||||
|
addConfigurationPreset: "添加配置预设...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "窗口",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "帮助",
|
||||||
|
documentation: "文档",
|
||||||
|
reportIssue: "报告问题",
|
||||||
|
},
|
||||||
|
|
||||||
|
ja: {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "ファイル",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "編集",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "表示",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "設定",
|
||||||
|
switchPreset: "プリセット切り替え",
|
||||||
|
managePresets: "プリセット管理...",
|
||||||
|
addConfigurationPreset: "設定プリセットを追加...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "ウインドウ",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "ヘルプ",
|
||||||
|
documentation: "ドキュメント",
|
||||||
|
reportIssue: "問題を報告",
|
||||||
|
},
|
||||||
|
|
||||||
|
"zh-Hant": {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
file: "檔案",
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
edit: "編輯",
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
view: "檢視",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切換預設",
|
||||||
|
managePresets: "管理預設...",
|
||||||
|
addConfigurationPreset: "新增配置預設...",
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
window: "視窗",
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
help: "說明",
|
||||||
|
documentation: "文件",
|
||||||
|
reportIssue: "回報問題",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get menu translations for a given locale
|
||||||
|
* Falls back to English if locale is not supported
|
||||||
|
*/
|
||||||
|
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||||
|
// Check for zh-Hant before normalizing
|
||||||
|
if (
|
||||||
|
locale === "zh-Hant" ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hant") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-tw") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hk")
|
||||||
|
) {
|
||||||
|
return translations["zh-Hant"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||||
|
const normalized = locale.toLowerCase().split("-")[0]
|
||||||
|
|
||||||
|
if (normalized === "zh") return translations.zh
|
||||||
|
if (normalized === "ja") return translations.ja
|
||||||
|
return translations.en
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect system locale from Electron app
|
||||||
|
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
||||||
|
*/
|
||||||
|
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||||
|
const lower = appLocale.toLowerCase()
|
||||||
|
|
||||||
|
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||||
|
if (
|
||||||
|
lower.startsWith("zh-hant") ||
|
||||||
|
lower.startsWith("zh-tw") ||
|
||||||
|
lower.startsWith("zh-hk")
|
||||||
|
) {
|
||||||
|
return "zh-Hant"
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = lower.split("-")[0]
|
||||||
|
|
||||||
|
if (normalized === "zh") return "zh"
|
||||||
|
if (normalized === "ja") return "ja"
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get locale from stored preference or system default
|
||||||
|
* Checks config file for user's language preference first
|
||||||
|
*/
|
||||||
|
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||||
|
// Try to get from saved preference first
|
||||||
|
const savedLocale = getUserLocale()
|
||||||
|
if (savedLocale) {
|
||||||
|
return savedLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to system locale
|
||||||
|
return detectSystemLocale(appLocale)
|
||||||
|
}
|
||||||
@@ -7,9 +7,20 @@ import {
|
|||||||
getServerUrl,
|
getServerUrl,
|
||||||
isPortAvailable,
|
isPortAvailable,
|
||||||
} from "./port-manager"
|
} from "./port-manager"
|
||||||
|
import { setAppUrl } from "./window-manager"
|
||||||
|
|
||||||
let serverProcess: UtilityProcess | null = null
|
let serverProcess: UtilityProcess | null = null
|
||||||
|
|
||||||
|
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||||
|
// preset switches) can't leave two servers running
|
||||||
|
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
|
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const result = serverQueue.then(task)
|
||||||
|
serverQueue = result.catch(() => {})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the standalone server resources
|
* Get the path to the standalone server resources
|
||||||
* In packaged app: resources/standalone
|
* In packaged app: resources/standalone
|
||||||
@@ -45,7 +56,11 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
|||||||
* Start the Next.js standalone server using Electron's utilityProcess
|
* Start the Next.js standalone server using Electron's utilityProcess
|
||||||
* This API is designed for running Node.js code in the background
|
* This API is designed for running Node.js code in the background
|
||||||
*/
|
*/
|
||||||
export async function startNextServer(): Promise<string> {
|
export function startNextServer(): Promise<string> {
|
||||||
|
return runExclusive(startServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(): Promise<string> {
|
||||||
const resourcePath = getResourcePath()
|
const resourcePath = getResourcePath()
|
||||||
const serverPath = path.join(resourcePath, "server.js")
|
const serverPath = path.join(resourcePath, "server.js")
|
||||||
|
|
||||||
@@ -68,9 +83,16 @@ export async function startNextServer(): Promise<string> {
|
|||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "127.0.0.1",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
|
// The preset keys are the user's own, not a server's
|
||||||
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
||||||
|
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
||||||
|
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
@@ -96,23 +118,27 @@ export async function startNextServer(): Promise<string> {
|
|||||||
|
|
||||||
// Use Electron's utilityProcess API for running Node.js in background
|
// Use Electron's utilityProcess API for running Node.js in background
|
||||||
// This is the recommended way to run Node.js code in Electron
|
// This is the recommended way to run Node.js code in Electron
|
||||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
const proc = utilityProcess.fork(serverPath, [], {
|
||||||
cwd: resourcePath,
|
cwd: resourcePath,
|
||||||
env,
|
env,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
|
serverProcess = proc
|
||||||
|
|
||||||
serverProcess.stdout?.on("data", (data) => {
|
proc.stdout?.on("data", (data) => {
|
||||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
serverProcess.stderr?.on("data", (data) => {
|
proc.stderr?.on("data", (data) => {
|
||||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
serverProcess.on("exit", (code) => {
|
proc.on("exit", (code) => {
|
||||||
console.log(`Next.js server exited with code ${code}`)
|
console.log(`Next.js server exited with code ${code}`)
|
||||||
serverProcess = null
|
// An old server can exit after a new one started; keep the new one
|
||||||
|
if (serverProcess === proc) {
|
||||||
|
serverProcess = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = getServerUrl()
|
const url = getServerUrl()
|
||||||
@@ -126,39 +152,36 @@ export async function startNextServer(): Promise<string> {
|
|||||||
* Stop the Next.js server process and wait for it to exit
|
* Stop the Next.js server process and wait for it to exit
|
||||||
*/
|
*/
|
||||||
export async function stopNextServer(): Promise<void> {
|
export async function stopNextServer(): Promise<void> {
|
||||||
if (serverProcess) {
|
const proc = serverProcess
|
||||||
console.log("Stopping Next.js server...")
|
if (!proc) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log("Stopping Next.js server...")
|
||||||
|
serverProcess = null
|
||||||
|
|
||||||
// Create a promise that resolves when the process exits
|
// Resolves true when the process exits, false after the timeout
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
const waitForExit = (ms: number) =>
|
||||||
const proc = serverProcess
|
new Promise<boolean>((resolve) => {
|
||||||
if (!proc) {
|
proc.once("exit", () => resolve(true))
|
||||||
resolve()
|
setTimeout(() => resolve(false), ms)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onExit = () => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.once("exit", onExit)
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
proc.removeListener("exit", onExit)
|
|
||||||
resolve()
|
|
||||||
}, 5000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
serverProcess.kill()
|
proc.kill()
|
||||||
serverProcess = null
|
|
||||||
|
|
||||||
// Wait for process to exit
|
// Next.js waits for open requests (e.g. a streaming reply) before it
|
||||||
await exitPromise
|
// exits, so force kill it if it is still running after 5 seconds
|
||||||
|
if (!(await waitForExit(5000)) && proc.pid) {
|
||||||
// Additional wait for OS to release port
|
console.warn("Next.js server did not exit in time, force killing it")
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
try {
|
||||||
|
process.kill(proc.pid, "SIGKILL")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to force kill Next.js server:", error)
|
||||||
|
}
|
||||||
|
await waitForExit(2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional wait for OS to release port
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Restart the Next.js server with new environment variables
|
* Restart the Next.js server with new environment variables
|
||||||
*/
|
*/
|
||||||
export async function restartNextServer(): Promise<string> {
|
export function restartNextServer(): Promise<string> {
|
||||||
console.log("Restarting Next.js server...")
|
return runExclusive(async () => {
|
||||||
|
console.log("Restarting Next.js server...")
|
||||||
|
|
||||||
// Stop the current server and wait for it to exit
|
// Stop the current server and wait for it to exit
|
||||||
await stopNextServer()
|
await stopNextServer()
|
||||||
|
|
||||||
// Wait for the port to be released
|
// Wait for the port to be released
|
||||||
await waitForServerStop()
|
await waitForServerStop()
|
||||||
|
|
||||||
// Start the server again
|
// Start the server again, and follow it if it moved to another port
|
||||||
return startNextServer()
|
const url = await startServer()
|
||||||
|
setAppUrl(url)
|
||||||
|
return url
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-13
@@ -1,4 +1,6 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
import net from "node:net"
|
import net from "node:net"
|
||||||
|
import path from "node:path"
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,9 +11,11 @@ import { app } from "electron"
|
|||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Production mode uses fixed port (61337) to preserve localStorage
|
// Legacy production port — tried first to preserve localStorage for existing users
|
||||||
// Falls back to sequential ports if unavailable
|
legacyProduction: 61337,
|
||||||
production: 61337,
|
// New production port below the ephemeral range (49152-65535)
|
||||||
|
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
||||||
|
production: 13370,
|
||||||
// Maximum attempts to find an available port (fallback)
|
// Maximum attempts to find an available port (fallback)
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
@@ -21,13 +25,79 @@ const PORT_CONFIG = {
|
|||||||
*/
|
*/
|
||||||
let allocatedPort: number | null = null
|
let allocatedPort: number | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
|
||||||
|
* each origin's IndexedDB in its own folder
|
||||||
|
*/
|
||||||
|
function hasStoredData(port: number): boolean {
|
||||||
|
return existsSync(
|
||||||
|
path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"IndexedDB",
|
||||||
|
`http_127.0.0.1_${port}.indexeddb.leveldb`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two fixed production ports, the only ones whose origin (and so its
|
||||||
|
// chats and settings) is the same at every launch
|
||||||
|
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
|
|
||||||
|
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
||||||
|
|
||||||
|
/** The fixed port where a chat was last saved, if known */
|
||||||
|
function readChatPort(): number | null {
|
||||||
|
try {
|
||||||
|
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
||||||
|
return HOME_PORTS.includes(port) ? port : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChatPort(port: number): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not save the chat port:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page saved a chat: open on this port next time. Chats of the two
|
||||||
|
* ports cannot be shown together (each origin has its own storage), so the
|
||||||
|
* app opens where the user last worked. A launch that had to use the other
|
||||||
|
* port and saved nothing does not move it.
|
||||||
|
*/
|
||||||
|
export function rememberChatPort(): void {
|
||||||
|
const port = allocatedPort
|
||||||
|
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||||
|
if (readChatPort() !== port) writeChatPort(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page loaded without any chats. Before any chat was saved under this
|
||||||
|
* version (no file yet), the user's chats may be on the other fixed port,
|
||||||
|
* where an older version opened: try it first next time.
|
||||||
|
*/
|
||||||
|
export function noteNoChats(): void {
|
||||||
|
const port = allocatedPort
|
||||||
|
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||||
|
if (existsSync(chatPortFile())) return
|
||||||
|
const other = HOME_PORTS.find((p) => p !== port)
|
||||||
|
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific port is available
|
* Check if a specific port is available
|
||||||
*/
|
*/
|
||||||
export function isPortAvailable(port: number): Promise<boolean> {
|
export function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once("error", () => resolve(false))
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -39,12 +109,13 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses fixed port (61337) to preserve localStorage
|
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||||
|
* localStorage; 13370 first when only it has saved chats
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
|
* - Last resort: lets the OS assign a port (port 0)
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
* @throws Error if no available port found after max attempts
|
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
@@ -64,10 +135,24 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try preferred port first
|
// In production, first the port where a chat was last saved. Without
|
||||||
if (await isPortAvailable(preferredPort)) {
|
// one, the legacy port first to preserve existing users' data, unless
|
||||||
allocatedPort = preferredPort
|
// only the new port has data: their app started on 13370 while Windows
|
||||||
return preferredPort
|
// reserved 61337, and 61337 being free now would hide it
|
||||||
|
const chatPort = isDev ? null : readChatPort()
|
||||||
|
const candidates = isDev
|
||||||
|
? [preferredPort]
|
||||||
|
: chatPort !== null
|
||||||
|
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||||
|
: hasStoredData(PORT_CONFIG.production) &&
|
||||||
|
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||||
|
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||||
|
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
|
for (const port of candidates) {
|
||||||
|
if (await isPortAvailable(port)) {
|
||||||
|
allocatedPort = port
|
||||||
|
return port
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -84,9 +169,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
// Last resort: let the OS pick an available port
|
||||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
console.warn(
|
||||||
|
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
||||||
)
|
)
|
||||||
|
const osPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.once("listening", () => {
|
||||||
|
const addr = server.address()
|
||||||
|
const port = (addr as net.AddressInfo).port
|
||||||
|
server.close(() => resolve(port))
|
||||||
|
})
|
||||||
|
server.listen(0, "127.0.0.1")
|
||||||
|
})
|
||||||
|
allocatedPort = osPort
|
||||||
|
console.log(`OS assigned port: ${osPort}`)
|
||||||
|
return osPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,5 +212,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://localhost:${allocatedPort}`
|
return `http://127.0.0.1:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ function getConfigPath(): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load proxy configuration from JSON file
|
* Load proxy configuration from JSON file
|
||||||
|
* Returns null if the user never saved proxy settings (or the file is invalid)
|
||||||
*/
|
*/
|
||||||
export function loadProxyConfig(): ProxyConfig {
|
export function loadProxyConfig(): ProxyConfig | null {
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const data = fs.readFileSync(configPath, "utf-8")
|
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||||
return JSON.parse(data) as ProxyConfig
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
return data as ProxyConfig
|
||||||
|
}
|
||||||
|
console.error("Ignoring invalid proxy config:", data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load proxy config:", error)
|
console.error("Failed to load proxy config:", error)
|
||||||
}
|
}
|
||||||
return {}
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig {
|
|||||||
export function saveProxyConfig(config: ProxyConfig): void {
|
export function saveProxyConfig(config: ProxyConfig): void {
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
|
// a truncated file
|
||||||
|
const tempPath = `${configPath}.tmp`
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
|
fs.renameSync(tempPath, configPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save proxy config:", error)
|
console.error("Failed to save proxy config:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void {
|
|||||||
export function applyProxyToEnv(): void {
|
export function applyProxyToEnv(): void {
|
||||||
const config = loadProxyConfig()
|
const config = loadProxyConfig()
|
||||||
|
|
||||||
|
// No saved settings: keep proxy vars inherited from the system or .env
|
||||||
|
if (!config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (config.httpProxy) {
|
if (config.httpProxy) {
|
||||||
process.env.HTTP_PROXY = config.httpProxy
|
process.env.HTTP_PROXY = config.httpProxy
|
||||||
process.env.http_proxy = config.httpProxy
|
process.env.http_proxy = config.httpProxy
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron"
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
// URL of the app server the main window loads
|
||||||
|
let appUrl: string | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the icon path based on platform
|
* Get the icon path based on platform
|
||||||
* Note: electron-builder converts icon.png during packaging,
|
* Note: electron-builder converts icon.png during packaging,
|
||||||
@@ -28,6 +31,7 @@ function getIconPath(): string | undefined {
|
|||||||
* Create the main application window
|
* Create the main application window
|
||||||
*/
|
*/
|
||||||
export function createWindow(serverUrl: string): BrowserWindow {
|
export function createWindow(serverUrl: string): BrowserWindow {
|
||||||
|
appUrl = serverUrl
|
||||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -56,17 +60,28 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Open DevTools in development
|
// Open DevTools in development
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (!app.isPackaged) {
|
||||||
mainWindow.webContents.openDevTools()
|
mainWindow.webContents.openDevTools()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override the draw.io iframe's beforeunload handler so the window can
|
||||||
|
// close after the user edits text in a shape (fixes #815). Diagrams are
|
||||||
|
// already persisted via autosave, so the prompt is unnecessary.
|
||||||
|
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
mainWindow.on("closed", () => {
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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()
|
||||||
@@ -82,3 +97,39 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow
|
return mainWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app server URL the main window loads
|
||||||
|
*/
|
||||||
|
export function getAppUrl(): string | null {
|
||||||
|
return appUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point the main window at the restarted app server (it can come up on a
|
||||||
|
* different port). On the same port the page fetches the new preset's
|
||||||
|
* server models instead of sending the old preset's choice; it is not
|
||||||
|
* reloaded, which would drop unsent attachments.
|
||||||
|
*/
|
||||||
|
export function setAppUrl(url: string): void {
|
||||||
|
if (url === appUrl) {
|
||||||
|
mainWindow?.webContents.send("server-restarted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appUrl = url
|
||||||
|
mainWindow?.loadURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL belongs to the app server (same origin)
|
||||||
|
*/
|
||||||
|
export function isAppUrl(url: string | undefined): boolean {
|
||||||
|
if (!url || !appUrl) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(url).origin === new URL(appUrl).origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,27 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
maximize: () => ipcRenderer.send("window-maximize"),
|
maximize: () => ipcRenderer.send("window-maximize"),
|
||||||
close: () => ipcRenderer.send("window-close"),
|
close: () => ipcRenderer.send("window-close"),
|
||||||
|
|
||||||
// File operations
|
|
||||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
|
||||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
|
||||||
|
|
||||||
// Proxy settings
|
// Proxy settings
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||||
ipcRenderer.invoke("set-proxy", config),
|
ipcRenderer.invoke("set-proxy", config),
|
||||||
|
|
||||||
|
// User locale settings
|
||||||
|
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||||
|
setUserLocale: (locale: string) =>
|
||||||
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
|
|
||||||
|
// A chat was saved, or the page loaded with this many chats: the next
|
||||||
|
// launch opens the port where the chats are
|
||||||
|
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||||
|
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||||
|
|
||||||
|
// The server restarted on the same port (another preset)
|
||||||
|
onServerRestarted: (callback: () => void) => {
|
||||||
|
const listener = () => callback()
|
||||||
|
ipcRenderer.on("server-restarted", listener)
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener("server-restarted", listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ async function savePreset() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// closeModal() clears editingPresetId, so remember it for the toast
|
||||||
|
const isEdit = Boolean(editingPresetId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saveBtn.disabled = true
|
saveBtn.disabled = true
|
||||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||||
@@ -220,10 +223,7 @@ async function savePreset() {
|
|||||||
await window.settingsAPI.savePreset(preset)
|
await window.settingsAPI.savePreset(preset)
|
||||||
await loadPresets()
|
await loadPresets()
|
||||||
closeModal()
|
closeModal()
|
||||||
showToast(
|
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
||||||
editingPresetId ? "Preset updated" : "Preset created",
|
|
||||||
"success",
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save preset:", error)
|
console.error("Failed to save preset:", error)
|
||||||
showToast("Failed to save preset", "error")
|
showToast("Failed to save preset", "error")
|
||||||
@@ -265,8 +265,6 @@ async function applyPreset(id) {
|
|||||||
|
|
||||||
const result = await window.settingsAPI.applyPreset(id)
|
const result = await window.settingsAPI.applyPreset(id)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
currentPresetId = id
|
|
||||||
renderPresets()
|
|
||||||
showToast("Preset applied, server restarting...", "success")
|
showToast("Preset applied, server restarting...", "success")
|
||||||
} else {
|
} else {
|
||||||
showToast(result.error || "Failed to apply preset", "error")
|
showToast(result.error || "Failed to apply preset", "error")
|
||||||
@@ -274,6 +272,9 @@ async function applyPreset(id) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to apply preset:", error)
|
console.error("Failed to apply preset:", error)
|
||||||
showToast("Failed to apply preset", "error")
|
showToast("Failed to apply preset", "error")
|
||||||
|
} finally {
|
||||||
|
// Reload to show the active preset and reset the Apply button
|
||||||
|
await loadPresets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-6
@@ -1,12 +1,22 @@
|
|||||||
# 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, aihubmix, deepseek, siliconflow, gateway, novita
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
||||||
|
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||||
|
# The first one becomes the default and the rest appear in the model picker.
|
||||||
|
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||||
|
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
|
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
||||||
|
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
|
||||||
|
# and raise it only when they use their own API key, so this also caps cost on server keys.
|
||||||
|
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
||||||
|
# MAX_OUTPUT_TOKENS=64000
|
||||||
|
|
||||||
# AWS Bedrock Configuration
|
# AWS Bedrock Configuration
|
||||||
# AWS_REGION=us-east-1
|
# AWS_REGION=us-east-1
|
||||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||||
@@ -25,7 +35,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
|
|
||||||
# Anthropic (Direct) Configuration
|
# Anthropic (Direct) Configuration
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
|
||||||
|
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
|
||||||
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
||||||
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
||||||
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
||||||
@@ -33,13 +44,20 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Google Generative AI Configuration
|
# Google Generative AI Configuration
|
||||||
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
||||||
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
|
|
||||||
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
||||||
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
||||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
||||||
# 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,14 +69,19 @@ 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: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
|
||||||
|
# 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
|
||||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
|
# AIHubMix Configuration
|
||||||
|
# AIHUBMIX_API_KEY=your-aihubmix-api-key
|
||||||
|
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# DeepSeek Configuration
|
# DeepSeek Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||||
@@ -93,14 +116,32 @@ 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).
|
||||||
|
# Claude 4.7 and later reject it; the request is then retried without it.
|
||||||
# TEMPERATURE=0
|
# TEMPERATURE=0
|
||||||
|
|
||||||
|
# Debug Logging (Optional)
|
||||||
|
# Log the structure of the messages each chat request sends to the model
|
||||||
|
# DEBUG_LLM_PAYLOAD=true
|
||||||
|
|
||||||
# Access Control (Optional)
|
# Access Control (Optional)
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||||
|
|
||||||
|
# Admin Panel (Optional)
|
||||||
|
# Set a password to enable the web admin panel at /admin, where most of the
|
||||||
|
# settings in this file can be edited at runtime (stored in data/settings.json,
|
||||||
|
# which takes precedence over environment variables).
|
||||||
|
# Leave unset to disable the admin panel entirely.
|
||||||
|
# ADMIN_PASSWORD=your-admin-password
|
||||||
|
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||||
# Use this to point to a self-hosted draw.io instance
|
# Use this to point to a self-hosted draw.io instance
|
||||||
@@ -116,3 +157,55 @@ 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
|
||||||
|
|
||||||
|
# MiMo (Xiaomi) Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.xiaomimimo.com/
|
||||||
|
# MIMO_API_KEY=your_mimo_api_key
|
||||||
|
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
|
||||||
|
# Atlas Cloud Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.atlascloud.ai/console/api-keys
|
||||||
|
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
|
||||||
|
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
import type { DiagramOperation } from "@/components/chat/types"
|
||||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import type {
|
||||||
|
ValidationState,
|
||||||
|
ValidationStatus,
|
||||||
|
} from "@/components/chat/ValidationCard"
|
||||||
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||||
|
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
|
// display_diagram replaces the document with this one page
|
||||||
|
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||||
|
|
||||||
interface ToolCall {
|
interface ToolCall {
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
@@ -30,13 +41,38 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
|||||||
|
|
||||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||||
|
|
||||||
|
const MAX_VALIDATION_RETRIES = 3
|
||||||
|
|
||||||
|
// Type for the validation function passed from useValidateDiagram hook
|
||||||
|
type ValidateDiagramFn = (
|
||||||
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
) => Promise<ValidationResult>
|
||||||
|
|
||||||
interface UseDiagramToolHandlersParams {
|
interface UseDiagramToolHandlersParams {
|
||||||
partialXmlRef: MutableRefObject<string>
|
partialXmlRef: MutableRefObject<string>
|
||||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||||
|
// Tool calls the streaming preview must leave alone (shared with it)
|
||||||
|
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||||
|
// Failed VLM validations in the current user turn (reset on each user message)
|
||||||
|
validationRetryCountRef: MutableRefObject<number>
|
||||||
chartXMLRef: MutableRefObject<string>
|
chartXMLRef: MutableRefObject<string>
|
||||||
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
|
||||||
|
// Called when a screenshot check begins; the function it returns
|
||||||
|
// tells whether the user pressed Stop in this turn, also after the next
|
||||||
|
// message was sent. A check that has not started then is skipped (one
|
||||||
|
// already running is cancelled by the caller).
|
||||||
|
watchStop?: () => () => boolean
|
||||||
|
onValidationStateChange?: (
|
||||||
|
toolCallId: string,
|
||||||
|
state: ValidationState,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,11 +85,38 @@ interface UseDiagramToolHandlersParams {
|
|||||||
export function useDiagramToolHandlers({
|
export function useDiagramToolHandlers({
|
||||||
partialXmlRef,
|
partialXmlRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
|
processedToolCallsRef,
|
||||||
|
validationRetryCountRef,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
onExport,
|
onExport,
|
||||||
|
captureValidationPng,
|
||||||
|
validateDiagram,
|
||||||
|
enableVlmValidation = true,
|
||||||
|
sessionId,
|
||||||
|
watchStop,
|
||||||
|
onValidationStateChange,
|
||||||
}: UseDiagramToolHandlersParams) {
|
}: UseDiagramToolHandlersParams) {
|
||||||
|
// 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,
|
||||||
@@ -64,18 +127,39 @@ export function useDiagramToolHandlers({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processedToolCallsRef.current.add(toolCall.toolCallId)
|
||||||
|
// Only display_diagram, edit_diagram and a completing append_diagram
|
||||||
|
// put their result on the canvas. Other tools (get_shape_library,
|
||||||
|
// which the server runs, still arrives here) leave the stored
|
||||||
|
// originals for the preview code to undo.
|
||||||
if (toolCall.toolName === "display_diagram") {
|
if (toolCall.toolName === "display_diagram") {
|
||||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals())
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
await handleEditDiagram(toolCall, addToolOutput)
|
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
|
||||||
} else if (toolCall.toolName === "append_diagram") {
|
} else if (toolCall.toolName === "append_diagram") {
|
||||||
handleAppendDiagram(toolCall, addToolOutput)
|
handleAppendDiagram(toolCall, addToolOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored originals belong to previews not handled yet: this call's, and
|
||||||
|
// those of earlier calls with invalid input, which never get to the
|
||||||
|
// handler. The first is the diagram before all of them. A call that
|
||||||
|
// draws its result replaces those previews, so the preview code must
|
||||||
|
// neither draw them again nor undo them later. Returns that first one.
|
||||||
|
const takeOriginals = (): string | undefined => {
|
||||||
|
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||||
|
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||||
|
processedToolCallsRef.current.add(id)
|
||||||
|
}
|
||||||
|
editDiagramOriginalXmlRef.current.clear()
|
||||||
|
return originalXml
|
||||||
|
}
|
||||||
|
|
||||||
|
// originalXml: the diagram before the streamed previews, if any were drawn
|
||||||
const handleDisplayDiagram = async (
|
const handleDisplayDiagram = async (
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
|
originalXml: string | undefined,
|
||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
@@ -124,14 +208,18 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
|||||||
const finalXml = xml
|
const finalXml = xml
|
||||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||||
|
|
||||||
// Wrap raw XML with full mxfile structure for draw.io
|
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||||
const fullXml = wrapWithMxFile(finalXml)
|
// create_new_diagram, then load it
|
||||||
|
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||||
// loadDiagram validates and returns error if invalid
|
const validationError = prepared.ok
|
||||||
const validationError = onDisplayChart(fullXml)
|
? onDisplayChart(prepared.xml, true)
|
||||||
|
: prepared.error
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
console.warn("[display_diagram] Validation error:", validationError)
|
console.warn("[display_diagram] Validation error:", validationError)
|
||||||
|
// Undo the streamed preview, as a failed edit does: the canvas
|
||||||
|
// keeps the diagram from before this failed call
|
||||||
|
if (originalXml) onDisplayChart(originalXml, true)
|
||||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -155,7 +243,163 @@ ${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 &&
|
||||||
|
// At most this many checks per user turn, passed or not
|
||||||
|
validationRetryCountRef.current < MAX_VALIDATION_RETRIES
|
||||||
|
) {
|
||||||
|
let capturedPngData: string | null = null
|
||||||
|
const stopped = watchStop?.()
|
||||||
|
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()
|
||||||
|
// Stopped while the screenshot was taken: no check. The
|
||||||
|
// chat waits for this handler, so it must end now.
|
||||||
|
if (stopped?.()) {
|
||||||
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
|
} else if (capturedPngData) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Captured PNG for validation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each retry is a new tool call, so count attempts
|
||||||
|
// per user turn (the chat resets it when the user sends)
|
||||||
|
const attempt = validationRetryCountRef.current + 1
|
||||||
|
validationRetryCountRef.current = attempt
|
||||||
|
|
||||||
|
// Notify UI that we're validating (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"validating",
|
||||||
|
{
|
||||||
|
attempt,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await validateDiagram(
|
||||||
|
capturedPngData,
|
||||||
|
sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||||
|
const feedback =
|
||||||
|
formatValidationFeedback(result)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
||||||
|
result.issues,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI of validation failure (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"failed",
|
||||||
|
{
|
||||||
|
attempt,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
result,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Last attempt - accept the diagram with warning
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
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) {
|
||||||
|
// Cancelled by Stop: the diagram stays, unchecked
|
||||||
|
if ((error as Error)?.name === "AbortError") {
|
||||||
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Successfully displayed the diagram.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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({
|
||||||
@@ -171,21 +415,24 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// originalXml: the diagram before the streamed previews, if any were drawn.
|
||||||
|
// Operations apply to it, the same base XML that streaming used.
|
||||||
const handleEditDiagram = async (
|
const handleEditDiagram = async (
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
|
originalXml: string | undefined,
|
||||||
) => {
|
) => {
|
||||||
const { operations } = toolCall.input as {
|
const { operations } = toolCall.input as {
|
||||||
operations: DiagramOperation[]
|
operations: DiagramOperation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentXml = ""
|
let currentXml = ""
|
||||||
|
// On failure, undo the streaming preview so the canvas matches the XML
|
||||||
|
// reported back to the model
|
||||||
|
const restoreOriginal = () => {
|
||||||
|
if (originalXml) onDisplayChart(originalXml, true)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
|
||||||
// This ensures we apply operations to the same base XML that streaming used
|
|
||||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
if (originalXml) {
|
if (originalXml) {
|
||||||
currentXml = originalXml
|
currentXml = originalXml
|
||||||
} else {
|
} else {
|
||||||
@@ -199,26 +446,19 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
// All or nothing, checked like the MCP server's edit_diagram.
|
||||||
const { result: editedXml, errors } = applyDiagramOperations(
|
// The model sees the first page, so edits target it.
|
||||||
currentXml,
|
const outcome = editDiagram(currentXml, operations, {})
|
||||||
operations,
|
if (!outcome.ok) {
|
||||||
)
|
const reason = outcome.pageError
|
||||||
|
? outcome.errors[0]
|
||||||
// Check for operation errors
|
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
|
||||||
if (errors.length > 0) {
|
restoreOriginal()
|
||||||
const errorMessages = errors
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
|
||||||
)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: `Some operations failed:\n${errorMessages}
|
errorText: `${reason}
|
||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
@@ -227,49 +467,23 @@ ${currentXml}
|
|||||||
|
|
||||||
Please check the cell IDs and retry.`,
|
Please check the cell IDs and retry.`,
|
||||||
})
|
})
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDiagram validates and returns error if invalid
|
onDisplayChart(outcome.xml, true)
|
||||||
const validationError = onDisplayChart(editedXml)
|
|
||||||
if (validationError) {
|
|
||||||
console.warn(
|
|
||||||
"[edit_diagram] Validation error:",
|
|
||||||
validationError,
|
|
||||||
)
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Edit produced invalid XML: ${validationError}
|
|
||||||
|
|
||||||
Current diagram XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${currentXml}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please fix the operations to avoid structural issues.`,
|
|
||||||
})
|
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onExport()
|
onExport()
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
|
||||||
})
|
})
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[edit_diagram] Failed:", error)
|
console.error("[edit_diagram] Failed:", error)
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
restoreOriginal()
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
@@ -283,8 +497,6 @@ ${currentXml || "No XML available"}
|
|||||||
|
|
||||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||||
})
|
})
|
||||||
// Clean up the shared original XML ref even on error
|
|
||||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +506,19 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
|||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
|
// Nothing to continue: loading the fragment alone would replace the whole diagram
|
||||||
|
if (!partialXmlRef.current) {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
|
||||||
|
|
||||||
|
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Detect if LLM incorrectly started fresh instead of continuing
|
// Detect if LLM incorrectly started fresh instead of continuing
|
||||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||||
const trimmed = xml.trim()
|
const trimmed = xml.trim()
|
||||||
@@ -332,10 +557,18 @@ Start your continuation with the NEXT character after where it stopped.`,
|
|||||||
const finalXml = partialXmlRef.current
|
const finalXml = partialXmlRef.current
|
||||||
partialXmlRef.current = "" // Reset
|
partialXmlRef.current = "" // Reset
|
||||||
|
|
||||||
const fullXml = wrapWithMxFile(finalXml)
|
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||||
const validationError = onDisplayChart(fullXml)
|
// It draws now: it takes the stored originals, as display_diagram
|
||||||
|
const originalXml = prepared.ok ? takeOriginals() : undefined
|
||||||
|
const validationError = prepared.ok
|
||||||
|
? onDisplayChart(prepared.xml, true)
|
||||||
|
: prepared.error
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
|
// Loading failed: back to the diagram before the previews
|
||||||
|
if (prepared.ok && originalXml) {
|
||||||
|
onDisplayChart(originalXml, true)
|
||||||
|
}
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "append_diagram",
|
tool: "append_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
|
|||||||
+215
-28
@@ -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,6 +13,7 @@ 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"
|
||||||
@@ -61,6 +64,28 @@ function migrateOldConfig(): MultiModelConfig | null {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isKnownProvider = (p: { provider: string }) =>
|
||||||
|
Object.hasOwn(PROVIDER_INFO, p.provider)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored config without providers this version does not know (saved
|
||||||
|
* by another version, or edited by hand): they would break every list of
|
||||||
|
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
||||||
|
*/
|
||||||
|
function parseStoredConfig(stored: string): MultiModelConfig {
|
||||||
|
const config = JSON.parse(stored) as MultiModelConfig
|
||||||
|
const known = config.providers.filter(isKnownProvider)
|
||||||
|
if (known.length < config.providers.length) {
|
||||||
|
console.warn(
|
||||||
|
"Skipped saved providers this version does not know:",
|
||||||
|
config.providers
|
||||||
|
.filter((p) => !isKnownProvider(p))
|
||||||
|
.map((p) => p.provider),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { ...config, providers: known }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load config from localStorage
|
* Load config from localStorage
|
||||||
*/
|
*/
|
||||||
@@ -71,7 +96,7 @@ function loadConfig(): MultiModelConfig {
|
|||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(stored) as MultiModelConfig
|
return parseStoredConfig(stored)
|
||||||
} catch {
|
} catch {
|
||||||
console.error("Failed to parse model config")
|
console.error("Failed to parse model config")
|
||||||
}
|
}
|
||||||
@@ -96,7 +121,35 @@ function loadConfig(): MultiModelConfig {
|
|||||||
*/
|
*/
|
||||||
function saveConfig(config: MultiModelConfig): void {
|
function saveConfig(config: MultiModelConfig): void {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
// Providers this version does not know are not in config: keep them,
|
||||||
|
// with their keys, for the version that saved them
|
||||||
|
let unknown: MultiModelConfig["providers"] = []
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
|
if (stored) {
|
||||||
|
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
||||||
|
(p) => !isKnownProvider(p),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unreadable: nothing to keep
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.modelConfigs,
|
||||||
|
JSON.stringify({
|
||||||
|
...config,
|
||||||
|
providers: [...config.providers, ...unknown],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server model to fall back to: the one marked default, else the first one
|
||||||
|
*/
|
||||||
|
function defaultServerModelId(
|
||||||
|
serverModels: FlattenedServerModel[],
|
||||||
|
): string | undefined {
|
||||||
|
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseModelConfigReturn {
|
export interface UseModelConfigReturn {
|
||||||
@@ -132,14 +185,84 @@ 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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Pick up config changes saved by other tabs, so this tab neither shows a
|
||||||
|
// stale model nor overwrites their changes on its next save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", handleStorage)
|
||||||
|
return () => window.removeEventListener("storage", handleStorage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load server models on mount (if any), and again when the desktop app
|
||||||
|
// restarted its server for another preset
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
loadServerModels()
|
||||||
|
return window.electronAPI?.onServerRestarted?.(loadServerModels)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function loadServerModels() {
|
||||||
|
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 the default server model if no model is selected,
|
||||||
|
// or if the saved server model is gone (renamed or removed)
|
||||||
|
setConfig((prev) => {
|
||||||
|
const id = prev.selectedModelId
|
||||||
|
const isStale =
|
||||||
|
id?.startsWith("server:") &&
|
||||||
|
!raw.some((m) => m.id === id)
|
||||||
|
if (id && !isStale) return prev
|
||||||
|
// Saved before non-ASCII characters in provider names
|
||||||
|
// got into the id: they were dropped from it
|
||||||
|
const renamed = raw.filter(
|
||||||
|
(m) =>
|
||||||
|
`server:${m.providerLabel
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
|
||||||
|
)
|
||||||
|
const fallback =
|
||||||
|
renamed.length === 1
|
||||||
|
? renamed[0].id
|
||||||
|
: defaultServerModelId(raw)
|
||||||
|
return fallback === id
|
||||||
|
? prev
|
||||||
|
: { ...prev, selectedModelId: fallback }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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) {
|
||||||
@@ -148,9 +271,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
|
||||||
@@ -192,24 +339,31 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteProvider = useCallback((providerId: string) => {
|
const deleteProvider = useCallback(
|
||||||
setConfig((prev) => {
|
(providerId: string) => {
|
||||||
const provider = prev.providers.find((p) => p.id === providerId)
|
setConfig((prev) => {
|
||||||
const modelIds = provider?.models.map((m) => m.id) || []
|
const provider = prev.providers.find((p) => p.id === providerId)
|
||||||
|
const modelIds = provider?.models.map((m) => m.id) || []
|
||||||
|
|
||||||
// Clear selected model if it belongs to deleted provider
|
// Fall back to the default server model if the selected model
|
||||||
const newSelectedId =
|
// belongs to the deleted provider
|
||||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
const newSelectedId =
|
||||||
? undefined
|
prev.selectedModelId &&
|
||||||
: prev.selectedModelId
|
modelIds.includes(prev.selectedModelId)
|
||||||
|
? defaultServerModelId(serverModels)
|
||||||
|
: prev.selectedModelId
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
providers: prev.providers.filter(
|
||||||
selectedModelId: newSelectedId,
|
(p) => p.id !== providerId,
|
||||||
}
|
),
|
||||||
})
|
selectedModelId: newSelectedId,
|
||||||
}, [])
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[serverModels],
|
||||||
|
)
|
||||||
|
|
||||||
const addModel = useCallback(
|
const addModel = useCallback(
|
||||||
(providerId: string, modelId: string): ModelConfig => {
|
(providerId: string, modelId: string): ModelConfig => {
|
||||||
@@ -266,14 +420,15 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
// Clear selected model if it was deleted
|
// Fall back to the default server model if the selected model
|
||||||
|
// was deleted
|
||||||
selectedModelId:
|
selectedModelId:
|
||||||
prev.selectedModelId === modelConfigId
|
prev.selectedModelId === modelConfigId
|
||||||
? undefined
|
? defaultServerModelId(serverModels)
|
||||||
: prev.selectedModelId,
|
: prev.selectedModelId,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
[],
|
[serverModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
const resetConfig = useCallback(() => {
|
||||||
@@ -282,7 +437,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoaded,
|
isLoaded: isLoaded && serverLoaded,
|
||||||
models,
|
models,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
@@ -314,6 +469,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: "",
|
||||||
@@ -325,6 +484,8 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
|
selectedModelId: "",
|
||||||
|
vertexApiKey: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
if (typeof window === "undefined") return empty
|
||||||
@@ -347,22 +508,45 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
|
selectedModelId: "",
|
||||||
|
vertexApiKey: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let config: MultiModelConfig
|
let config: MultiModelConfig
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(stored)
|
// Unknown providers would break the model lookup below
|
||||||
|
config = parseStoredConfig(stored)
|
||||||
} catch {
|
} catch {
|
||||||
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 }
|
||||||
@@ -379,5 +563,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 || "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-100
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import {
|
||||||
type ChatSession,
|
type ChatSession,
|
||||||
createEmptySession,
|
createEmptySession,
|
||||||
@@ -11,10 +13,12 @@ import {
|
|||||||
getSession,
|
getSession,
|
||||||
isIndexedDBAvailable,
|
isIndexedDBAvailable,
|
||||||
migrateFromLocalStorage,
|
migrateFromLocalStorage,
|
||||||
|
readSessionCount,
|
||||||
type SessionMetadata,
|
type SessionMetadata,
|
||||||
type StoredMessage,
|
type StoredMessage,
|
||||||
saveSession,
|
saveSession,
|
||||||
} from "@/lib/session-storage"
|
} from "@/lib/session-storage"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
messages: StoredMessage[]
|
messages: StoredMessage[]
|
||||||
@@ -24,6 +28,13 @@ export interface SessionData {
|
|||||||
diagramHistory?: { svg: string; xml: string }[]
|
diagramHistory?: { svg: string; xml: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taken right before a save's data is read: the chat on screen then, and
|
||||||
|
// the order of the reads
|
||||||
|
export interface SaveTicket {
|
||||||
|
generation: number
|
||||||
|
seq: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseSessionManagerReturn {
|
export interface UseSessionManagerReturn {
|
||||||
// State
|
// State
|
||||||
sessions: SessionMetadata[]
|
sessions: SessionMetadata[]
|
||||||
@@ -35,13 +46,27 @@ export interface UseSessionManagerReturn {
|
|||||||
// Actions
|
// Actions
|
||||||
switchSession: (id: string) => Promise<SessionData | null>
|
switchSession: (id: string) => Promise<SessionData | null>
|
||||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||||
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
// ticket: getSaveTicket() before the data was read (by default, now).
|
||||||
|
// The save is dropped if another chat is on screen when its turn comes,
|
||||||
|
// or if a copy of this chat read later was saved already.
|
||||||
|
// Resolves to false when the save failed (the user was told)
|
||||||
saveCurrentSession: (
|
saveCurrentSession: (
|
||||||
data: SessionData,
|
data: SessionData,
|
||||||
forSessionId?: string | null,
|
ticket?: SaveTicket,
|
||||||
) => Promise<void>
|
) => Promise<boolean>
|
||||||
refreshSessions: () => Promise<void>
|
refreshSessions: () => Promise<void>
|
||||||
clearCurrentSession: () => void
|
clearCurrentSession: () => void
|
||||||
|
getChatGeneration: () => number
|
||||||
|
getSaveTicket: () => SaveTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading the session list loads every stored session in full, and window
|
||||||
|
// focus also fires each time the user clicks back from the draw.io iframe
|
||||||
|
const FOCUS_REFRESH_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
function notifySaveFailed(message: string) {
|
||||||
|
// Same id, so repeated failures update one toast instead of stacking
|
||||||
|
toast.error(message, { id: "session-save-failed", duration: 8000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSessionManagerOptions {
|
interface UseSessionManagerOptions {
|
||||||
@@ -53,6 +78,7 @@ export function useSessionManager(
|
|||||||
options: UseSessionManagerOptions = {},
|
options: UseSessionManagerOptions = {},
|
||||||
): UseSessionManagerReturn {
|
): UseSessionManagerReturn {
|
||||||
const { initialSessionId } = options
|
const { initialSessionId } = options
|
||||||
|
const dict = useDictionary()
|
||||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -66,6 +92,23 @@ export function useSessionManager(
|
|||||||
const isInitializedRef = useRef(false)
|
const isInitializedRef = useRef(false)
|
||||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||||
const urlChangeSequenceRef = useRef(0)
|
const urlChangeSequenceRef = useRef(0)
|
||||||
|
// The chat on screen, read by saves that run after a render or a wait
|
||||||
|
const currentSessionRef = useRef<ChatSession | null>(null)
|
||||||
|
// Goes up each time another chat is put on screen (creating the
|
||||||
|
// session of the chat on screen does not count)
|
||||||
|
const chatGenerationRef = useRef(0)
|
||||||
|
// Saves run one at a time, so two saves of a new chat create it once
|
||||||
|
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||||
|
// The last ticket number, and that of the newest data saved
|
||||||
|
const saveSeqRef = useRef(0)
|
||||||
|
const savedSeqRef = useRef(0)
|
||||||
|
|
||||||
|
const changeChat = useCallback((session: ChatSession | null) => {
|
||||||
|
chatGenerationRef.current++
|
||||||
|
currentSessionRef.current = session
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session?.id ?? null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Load sessions list
|
// Load sessions list
|
||||||
const refreshSessions = useCallback(async () => {
|
const refreshSessions = useCallback(async () => {
|
||||||
@@ -101,14 +144,36 @@ export function useSessionManager(
|
|||||||
// Load sessions list
|
// Load sessions list
|
||||||
const metadata = await getAllSessionMetadata()
|
const metadata = await getAllSessionMetadata()
|
||||||
setSessions(metadata)
|
setSessions(metadata)
|
||||||
|
// The desktop app may try its other port next launch, where
|
||||||
|
// an older version may have saved the chats: only when this
|
||||||
|
// origin surely has none (a failed read is not "none") and
|
||||||
|
// keeps no model settings or keys either
|
||||||
|
if (window.electronAPI?.chatsLoaded) {
|
||||||
|
const count = await readSessionCount()
|
||||||
|
// The app saves an empty config on its first load; the
|
||||||
|
// providers are what holds the keys, besides an access
|
||||||
|
// code
|
||||||
|
let hasSettings = true
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
||||||
|
"{}",
|
||||||
|
)
|
||||||
|
hasSettings =
|
||||||
|
(config.providers?.length ?? 0) > 0 ||
|
||||||
|
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
||||||
|
} catch {
|
||||||
|
// Unreadable: treat as settings, and stay
|
||||||
|
}
|
||||||
|
if (count !== null && !hasSettings) {
|
||||||
|
window.electronAPI.chatsLoaded(count).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only load a session if initialSessionId is provided (from URL param)
|
// Only load a session if initialSessionId is provided (from URL param)
|
||||||
if (initialSessionId) {
|
if (initialSessionId) {
|
||||||
const session = await getSession(initialSessionId)
|
const session = await getSession(initialSessionId)
|
||||||
if (session) {
|
if (session) changeChat(session)
|
||||||
setCurrentSession(session)
|
|
||||||
setCurrentSessionId(session.id)
|
|
||||||
}
|
|
||||||
// If session not found, stay in blank state (URL has invalid 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)
|
// If no initialSessionId, start with blank state (no auto-restore)
|
||||||
@@ -120,7 +185,7 @@ export function useSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
}, [initialSessionId])
|
}, [initialSessionId, changeChat])
|
||||||
|
|
||||||
// Handle URL session ID changes after initialization
|
// Handle URL session ID changes after initialization
|
||||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||||
@@ -135,6 +200,7 @@ export function useSessionManager(
|
|||||||
|
|
||||||
async function handleSessionIdChange() {
|
async function handleSessionIdChange() {
|
||||||
if (initialSessionId) {
|
if (initialSessionId) {
|
||||||
|
const generation = chatGenerationRef.current
|
||||||
// URL has session ID - load it
|
// URL has session ID - load it
|
||||||
const session = await getSession(initialSessionId)
|
const session = await getSession(initialSessionId)
|
||||||
|
|
||||||
@@ -143,16 +209,13 @@ export function useSessionManager(
|
|||||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Another chat was put on screen meanwhile (New Chat right
|
||||||
|
// after this one got its session id in the URL): keep it
|
||||||
|
if (generation !== chatGenerationRef.current) return
|
||||||
|
|
||||||
if (session) {
|
// Only update if the session is different from current
|
||||||
// Only update if the session is different from current
|
if (session && currentSessionRef.current?.id !== session.id) {
|
||||||
setCurrentSessionId((current) => {
|
changeChat(session)
|
||||||
if (current !== session.id) {
|
|
||||||
setCurrentSession(session)
|
|
||||||
return session.id
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Removed: else clause that clears session
|
// Removed: else clause that clears session
|
||||||
@@ -161,11 +224,17 @@ export function useSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleSessionIdChange()
|
handleSessionIdChange()
|
||||||
}, [initialSessionId, isAvailable])
|
}, [initialSessionId, isAvailable, changeChat])
|
||||||
|
|
||||||
// Refresh sessions on window focus (multi-tab sync)
|
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
||||||
|
const lastFocusRefreshRef = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFocusRefreshRef.current = now
|
||||||
refreshSessions()
|
refreshSessions()
|
||||||
}
|
}
|
||||||
window.addEventListener("focus", handleFocus)
|
window.addEventListener("focus", handleFocus)
|
||||||
@@ -177,9 +246,11 @@ export function useSessionManager(
|
|||||||
async (id: string): Promise<SessionData | null> => {
|
async (id: string): Promise<SessionData | null> => {
|
||||||
if (id === currentSessionId) return null
|
if (id === currentSessionId) return null
|
||||||
|
|
||||||
// Save current session first if it has messages
|
// Save current session first if it has messages (as saved
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
// last: the caller may have just saved it)
|
||||||
await saveSession(currentSession)
|
const current = currentSessionRef.current
|
||||||
|
if (current && current.messages.length > 0) {
|
||||||
|
await saveSession(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the target session
|
// Load the target session
|
||||||
@@ -189,9 +260,7 @@ export function useSessionManager(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
changeChat(session)
|
||||||
setCurrentSession(session)
|
|
||||||
setCurrentSessionId(session.id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
@@ -201,7 +270,7 @@ export function useSessionManager(
|
|||||||
diagramHistory: session.diagramHistory,
|
diagramHistory: session.diagramHistory,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentSessionId, currentSession],
|
[currentSessionId, changeChat],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delete a session
|
// Delete a session
|
||||||
@@ -211,101 +280,137 @@ export function useSessionManager(
|
|||||||
await deleteSessionFromDB(id)
|
await deleteSessionFromDB(id)
|
||||||
|
|
||||||
// If deleting current session, clear state (caller will show new empty session)
|
// If deleting current session, clear state (caller will show new empty session)
|
||||||
if (wasCurrentSession) {
|
if (wasCurrentSession) changeChat(null)
|
||||||
setCurrentSession(null)
|
|
||||||
setCurrentSessionId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshSessions()
|
await refreshSessions()
|
||||||
|
|
||||||
return { wasCurrentSession }
|
return { wasCurrentSession }
|
||||||
},
|
},
|
||||||
[currentSessionId, refreshSessions],
|
[currentSessionId, refreshSessions, changeChat],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save current session data (debounced externally by caller)
|
// Save current session data (debounced externally by caller)
|
||||||
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
|
||||||
const saveCurrentSession = useCallback(
|
const saveCurrentSession = useCallback(
|
||||||
async (
|
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
||||||
data: SessionData,
|
// The data is of the chat on screen when it was read
|
||||||
forSessionId?: string | null,
|
const { generation, seq } = ticket ?? {
|
||||||
): Promise<void> => {
|
generation: chatGenerationRef.current,
|
||||||
// If forSessionId is provided, verify it matches current session
|
seq: ++saveSeqRef.current,
|
||||||
// This prevents stale debounced saves from overwriting a newly switched session
|
|
||||||
if (
|
|
||||||
forSessionId !== undefined &&
|
|
||||||
forSessionId !== currentSessionId
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
const run = async (): Promise<boolean> => {
|
||||||
|
// That chat is no longer on screen (leaving it saved it)
|
||||||
|
if (generation !== chatGenerationRef.current) return true
|
||||||
|
// A copy read later was saved already (one that waited for
|
||||||
|
// its thumbnail must not undo it)
|
||||||
|
if (seq < savedSeqRef.current) return true
|
||||||
|
// Nothing can be stored without IndexedDB
|
||||||
|
if (!isIndexedDBAvailable()) return true
|
||||||
|
// The user may put another chat on screen while this one is
|
||||||
|
// written; the stored copy is still right, the state is not
|
||||||
|
const stillOnScreen = () =>
|
||||||
|
chatGenerationRef.current === generation
|
||||||
|
const currentSession = currentSessionRef.current
|
||||||
|
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
// Create a new session if none exists
|
// Create a new session if none exists
|
||||||
const newSession: ChatSession = {
|
const newSession: ChatSession = {
|
||||||
...createEmptySession(),
|
...createEmptySession(),
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||||
|
diagramHistory: data.diagramHistory,
|
||||||
|
title: extractTitle(data.messages),
|
||||||
|
}
|
||||||
|
// Without a stored session, keep no session id (it would end
|
||||||
|
// up in the URL and point to nothing after a reload)
|
||||||
|
if (!(await saveSession(newSession))) {
|
||||||
|
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
savedSeqRef.current = seq
|
||||||
|
await enforceSessionLimit()
|
||||||
|
if (stillOnScreen()) {
|
||||||
|
currentSessionRef.current = newSession
|
||||||
|
setCurrentSession(newSession)
|
||||||
|
setCurrentSessionId(newSession.id)
|
||||||
|
}
|
||||||
|
await refreshSessions()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing session
|
||||||
|
const updatedSession: ChatSession = {
|
||||||
|
...currentSession,
|
||||||
messages: data.messages,
|
messages: data.messages,
|
||||||
xmlSnapshots: data.xmlSnapshots,
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
diagramXml: data.diagramXml,
|
diagramXml: data.diagramXml,
|
||||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
thumbnailDataUrl:
|
||||||
diagramHistory: data.diagramHistory,
|
data.thumbnailDataUrl ??
|
||||||
title: extractTitle(data.messages),
|
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(newSession)
|
|
||||||
await enforceSessionLimit()
|
if (!(await saveSession(updatedSession))) {
|
||||||
setCurrentSession(newSession)
|
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||||
setCurrentSessionId(newSession.id)
|
return false
|
||||||
await refreshSessions()
|
}
|
||||||
return
|
savedSeqRef.current = seq
|
||||||
|
if (stillOnScreen()) {
|
||||||
|
currentSessionRef.current = 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
const result = saveQueueRef.current.then(run)
|
||||||
// Update existing session
|
saveQueueRef.current = result.catch(() => {})
|
||||||
const updatedSession: ChatSession = {
|
return result
|
||||||
...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],
|
[refreshSessions, dict],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clear current session state (for starting fresh without loading another session)
|
// Clear current session state (for starting fresh without loading another session)
|
||||||
const clearCurrentSession = useCallback(() => {
|
const clearCurrentSession = useCallback(() => {
|
||||||
setCurrentSession(null)
|
changeChat(null)
|
||||||
setCurrentSessionId(null)
|
}, [changeChat])
|
||||||
}, [])
|
|
||||||
|
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||||
|
|
||||||
|
const getSaveTicket = useCallback(
|
||||||
|
(): SaveTicket => ({
|
||||||
|
generation: chatGenerationRef.current,
|
||||||
|
seq: ++saveSeqRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -318,5 +423,7 @@ export function useSessionManager(
|
|||||||
saveCurrentSession,
|
saveCurrentSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
clearCurrentSession,
|
clearCurrentSession,
|
||||||
|
getChatGeneration,
|
||||||
|
getSaveTicket,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"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 { getSelectedAIConfig } from "@/hooks/use-model-config"
|
||||||
|
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,
|
||||||
|
// Resolved per request so a changed access code is picked up
|
||||||
|
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a running check (the user pressed Stop): its promise rejects with
|
||||||
|
* an AbortError, so the tool handler can finish at once.
|
||||||
|
*/
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
const pending = pendingValidationRef.current
|
||||||
|
if (!pending) return
|
||||||
|
pendingValidationRef.current = null
|
||||||
|
stop()
|
||||||
|
pending.reject(new DOMException("Validation cancelled", "AbortError"))
|
||||||
|
}, [stop])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate with fallback - returns default valid result on error.
|
||||||
|
* Use this to avoid blocking the user on validation failures.
|
||||||
|
* A cancelled check is passed on as its AbortError.
|
||||||
|
*/
|
||||||
|
const validateWithFallback = useCallback(
|
||||||
|
async (
|
||||||
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<ValidationResult> => {
|
||||||
|
try {
|
||||||
|
return await validate(imageData, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error)?.name === "AbortError") throw error
|
||||||
|
console.warn(
|
||||||
|
"[useValidateDiagram] Validation failed, using fallback:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return DEFAULT_VALID_RESULT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Validation functions
|
||||||
|
validate,
|
||||||
|
validateWithFallback,
|
||||||
|
stop,
|
||||||
|
cancel,
|
||||||
|
|
||||||
|
// State
|
||||||
|
isValidating: isLoading,
|
||||||
|
partialResult: object as ValidationResult | undefined,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -1,7 +1,17 @@
|
|||||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||||
|
|
||||||
export function register() {
|
export async function register() {
|
||||||
|
// Overlay admin settings file onto process.env before anything reads config
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
try {
|
||||||
|
const { applyToEnv } = await import("@/lib/admin/settings")
|
||||||
|
applyToEnv()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin-settings] Failed to apply settings:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Skip telemetry if Langfuse env vars are not configured
|
// Skip telemetry if Langfuse env vars are not configured
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Refuse a POST that a page on another website could have sent. A browser
|
||||||
|
* sends a cross-site POST without asking first (CORS preflight) only with a
|
||||||
|
* text or form body, so the routes take JSON only. In the desktop app also
|
||||||
|
* refuse a foreign Host: a site that points its own domain name at
|
||||||
|
* 127.0.0.1 (DNS rebinding) is same-origin with the local server, but its
|
||||||
|
* requests carry that domain. A request the server builds itself has no
|
||||||
|
* Host. Returns the response to send, or null when the request may go on.
|
||||||
|
*/
|
||||||
|
export function rejectCrossSite(req: Request): Response | null {
|
||||||
|
const contentType = req.headers.get("content-type") ?? ""
|
||||||
|
if (!/^\s*application\/json\b/i.test(contentType)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Content-Type must be application/json" },
|
||||||
|
{ status: 415 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const host = req.headers.get("host")
|
||||||
|
if (
|
||||||
|
process.env.NEXT_AI_DRAWIO_DESKTOP === "1" &&
|
||||||
|
host &&
|
||||||
|
!/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host)
|
||||||
|
) {
|
||||||
|
return Response.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the x-access-code header against ACCESS_CODE_LIST.
|
||||||
|
* Returns a 401 response to send back when the check fails, or null when the
|
||||||
|
* request may continue (including when no access codes are configured).
|
||||||
|
*/
|
||||||
|
export function checkAccessCode(req: Request): Response | null {
|
||||||
|
const accessCodes =
|
||||||
|
process.env.ACCESS_CODE_LIST?.split(",")
|
||||||
|
.map((code) => code.trim())
|
||||||
|
.filter(Boolean) || []
|
||||||
|
if (accessCodes.length === 0) return null
|
||||||
|
|
||||||
|
const accessCodeHeader = req.headers.get("x-access-code")
|
||||||
|
if (accessCodeHeader && accessCodes.includes(accessCodeHeader)) return null
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { timingSafeEqual } from "crypto"
|
||||||
|
|
||||||
|
// Shared auth for admin API routes: compares x-admin-password header
|
||||||
|
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
|
||||||
|
export function checkAdminAuth(req: Request): Response | null {
|
||||||
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
if (!password) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const provided = req.headers.get("x-admin-password") || ""
|
||||||
|
const a = Buffer.from(provided)
|
||||||
|
const b = Buffer.from(password)
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid admin password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskedSecret {
|
||||||
|
isSet: true
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskSecret(value: string): MaskedSecret {
|
||||||
|
return {
|
||||||
|
isSet: true,
|
||||||
|
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import {
|
||||||
|
ProviderNameSchema,
|
||||||
|
type ServerModelsConfig,
|
||||||
|
slugify,
|
||||||
|
} from "@/lib/server-model-config"
|
||||||
|
import {
|
||||||
|
FIXED_CRED_PROVIDERS,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderName,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
import { type MaskedSecret, maskSecret } from "./auth"
|
||||||
|
import { loadSettings } from "./settings"
|
||||||
|
|
||||||
|
// Admin-configured providers, mirroring the user ModelConfigDialog's data
|
||||||
|
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
|
||||||
|
//
|
||||||
|
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
|
||||||
|
// loadRawServerModelsConfig() merges the env baseline with the panel's
|
||||||
|
// providers at read time, so .env stays authoritative for its own entries.
|
||||||
|
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
|
||||||
|
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
|
||||||
|
// OPENAI_API_KEY that env-based entries may rely on.
|
||||||
|
|
||||||
|
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
|
||||||
|
|
||||||
|
// A secret field in transit: plaintext string (new value) or an
|
||||||
|
// {isSet} marker meaning "keep the stored value".
|
||||||
|
const SecretInputSchema = z
|
||||||
|
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
|
||||||
|
.optional()
|
||||||
|
|
||||||
|
export const AdminProviderSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
provider: ProviderNameSchema,
|
||||||
|
name: z.string().optional(),
|
||||||
|
apiKey: SecretInputSchema,
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
awsAccessKeyId: SecretInputSchema,
|
||||||
|
awsSecretAccessKey: SecretInputSchema,
|
||||||
|
awsRegion: z.string().optional(),
|
||||||
|
vertexApiKey: SecretInputSchema,
|
||||||
|
models: z.array(z.string().min(1)),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AdminProvidersSchema = z.array(AdminProviderSchema)
|
||||||
|
|
||||||
|
// Stored shape: secrets are plain strings (never {isSet} markers, which
|
||||||
|
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
|
||||||
|
// hand-edited/corrupted value can't slip a marker object past maskSecret.
|
||||||
|
const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
awsAccessKeyId: z.string().optional(),
|
||||||
|
awsSecretAccessKey: z.string().optional(),
|
||||||
|
vertexApiKey: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
||||||
|
|
||||||
|
// Stored form: secrets are plain strings
|
||||||
|
export interface StoredAdminProvider {
|
||||||
|
id: string
|
||||||
|
provider: ProviderName
|
||||||
|
name?: string
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
vertexApiKey?: string
|
||||||
|
models: string[]
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_FIELDS = [
|
||||||
|
"apiKey",
|
||||||
|
"awsAccessKeyId",
|
||||||
|
"awsSecretAccessKey",
|
||||||
|
"vertexApiKey",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
|
||||||
|
function credEnvNames(
|
||||||
|
provider: ProviderName,
|
||||||
|
index: number,
|
||||||
|
): { key?: string; url?: string } {
|
||||||
|
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const prefix =
|
||||||
|
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
|
||||||
|
const suffix = index === 0 ? "" : `_${index + 1}`
|
||||||
|
return {
|
||||||
|
key: `ADMIN_${prefix}_API_KEY${suffix}`,
|
||||||
|
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAdminProviders(): StoredAdminProvider[] {
|
||||||
|
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
// Validate each entry's shape — a malformed/hand-edited value must
|
||||||
|
// not reach runtime code that assumes provider/models exist.
|
||||||
|
return parsed.flatMap((entry) => {
|
||||||
|
const result = StoredAdminProviderSchema.safeParse(entry)
|
||||||
|
return result.success ? [result.data as StoredAdminProvider] : []
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.error("[admin-providers] Failed to parse stored providers")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaskedAdminProvider = Omit<
|
||||||
|
StoredAdminProvider,
|
||||||
|
(typeof SECRET_FIELDS)[number]
|
||||||
|
> & {
|
||||||
|
apiKey?: MaskedSecret
|
||||||
|
awsAccessKeyId?: MaskedSecret
|
||||||
|
awsSecretAccessKey?: MaskedSecret
|
||||||
|
vertexApiKey?: MaskedSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskAdminProviders(
|
||||||
|
list: StoredAdminProvider[],
|
||||||
|
): MaskedAdminProvider[] {
|
||||||
|
return list.map((p) => {
|
||||||
|
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
|
||||||
|
for (const field of SECRET_FIELDS) {
|
||||||
|
const value = p[field]
|
||||||
|
masked[field] = value ? maskSecret(value) : undefined
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve {isSet} markers in incoming secrets against the stored list
|
||||||
|
export function mergeSecrets(
|
||||||
|
incoming: AdminProviderInput[],
|
||||||
|
stored: StoredAdminProvider[],
|
||||||
|
): StoredAdminProvider[] {
|
||||||
|
const storedById = new Map(stored.map((p) => [p.id, p]))
|
||||||
|
return incoming.map((p) => {
|
||||||
|
const prev = storedById.get(p.id)
|
||||||
|
const merged = { ...p } as StoredAdminProvider
|
||||||
|
for (const field of SECRET_FIELDS) {
|
||||||
|
const value = p[field]
|
||||||
|
if (typeof value === "string") {
|
||||||
|
merged[field] = value || undefined
|
||||||
|
} else if (value?.isSet) {
|
||||||
|
merged[field] = prev?.[field]
|
||||||
|
} else {
|
||||||
|
merged[field] = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(p: StoredAdminProvider): string {
|
||||||
|
return p.name?.trim() || PROVIDER_INFO[p.provider].label
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAdminProviders(
|
||||||
|
list: StoredAdminProvider[],
|
||||||
|
envConfig: ServerModelsConfig | null = null,
|
||||||
|
): string | null {
|
||||||
|
const envProviders = envConfig?.providers ?? []
|
||||||
|
for (const single of FIXED_CRED_PROVIDERS) {
|
||||||
|
if (list.filter((p) => p.provider === single).length > 1) {
|
||||||
|
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
|
||||||
|
}
|
||||||
|
// Its credentials are global; a panel instance would silently
|
||||||
|
// override the credentials env-configured models rely on
|
||||||
|
if (
|
||||||
|
list.some((p) => p.provider === single) &&
|
||||||
|
envProviders.some((p) => p.provider === single)
|
||||||
|
) {
|
||||||
|
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Server model ids are built from the slugified name, so names must
|
||||||
|
// stay distinct after slugifying ("OpenAI" and "openai" would collide)
|
||||||
|
const names = list.map((p) => displayName(p))
|
||||||
|
const slugs = names.map(slugify)
|
||||||
|
if (new Set(slugs).size !== slugs.length) {
|
||||||
|
return "Provider display names must be unique (ignoring case and punctuation)."
|
||||||
|
}
|
||||||
|
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
|
||||||
|
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
|
||||||
|
if (clash) {
|
||||||
|
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
|
||||||
|
}
|
||||||
|
if (list.filter((p) => p.isDefault).length > 1) {
|
||||||
|
return "Only one provider can be the default."
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel's contribution to the server models config, derived at read
|
||||||
|
// time and merged with the env baseline by loadRawServerModelsConfig().
|
||||||
|
export function adminProvidersToConfig(
|
||||||
|
list: StoredAdminProvider[],
|
||||||
|
): ServerModelsConfig {
|
||||||
|
const config: ServerModelsConfig = { providers: [] }
|
||||||
|
const indexByProvider = new Map<ProviderName, number>()
|
||||||
|
for (const p of list) {
|
||||||
|
const index = indexByProvider.get(p.provider) ?? 0
|
||||||
|
indexByProvider.set(p.provider, index + 1)
|
||||||
|
if (p.models.length === 0) continue
|
||||||
|
const env = credEnvNames(p.provider, index)
|
||||||
|
config.providers.push({
|
||||||
|
name: displayName(p),
|
||||||
|
provider: p.provider,
|
||||||
|
models: p.models,
|
||||||
|
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
|
||||||
|
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
|
||||||
|
...(p.isDefault ? { default: true } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings updates derived from the provider list: credential env vars,
|
||||||
|
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
|
||||||
|
// Keys derived from `previous` but absent now are set to null (removed,
|
||||||
|
// falling back to the environment).
|
||||||
|
export function deriveEnvUpdates(
|
||||||
|
list: StoredAdminProvider[],
|
||||||
|
previous: StoredAdminProvider[],
|
||||||
|
): Record<string, string | null> {
|
||||||
|
const updates: Record<string, string | null> = {}
|
||||||
|
|
||||||
|
// Clear everything the previous list owned, then overwrite below
|
||||||
|
for (const key of derivedEnvKeys(previous)) updates[key] = null
|
||||||
|
|
||||||
|
const indexByProvider = new Map<ProviderName, number>()
|
||||||
|
for (const p of list) {
|
||||||
|
const index = indexByProvider.get(p.provider) ?? 0
|
||||||
|
indexByProvider.set(p.provider, index + 1)
|
||||||
|
|
||||||
|
if (p.provider === "bedrock") {
|
||||||
|
// ADMIN_ names keep the standard AWS_* vars untouched, so other
|
||||||
|
// AWS clients (e.g. the DynamoDB quota table) keep their own
|
||||||
|
// credentials instead of picking up the panel's Bedrock keys
|
||||||
|
if (p.awsAccessKeyId)
|
||||||
|
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
||||||
|
if (p.awsSecretAccessKey)
|
||||||
|
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
||||||
|
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
|
||||||
|
} else if (p.provider === "vertexai") {
|
||||||
|
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
|
||||||
|
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
||||||
|
} else if (p.provider === "ollama") {
|
||||||
|
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
|
||||||
|
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
|
||||||
|
} else {
|
||||||
|
const env = credEnvNames(p.provider, index)
|
||||||
|
if (env.key && p.apiKey) updates[env.key] = p.apiKey
|
||||||
|
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
|
||||||
|
|
||||||
|
// The panel's default also becomes the server-wide default model;
|
||||||
|
// without one, the env-configured default applies.
|
||||||
|
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
|
||||||
|
if (defaultEntry) {
|
||||||
|
updates.AI_PROVIDER = defaultEntry.provider
|
||||||
|
updates.AI_MODEL = defaultEntry.models[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every settings key the panel may have written for a given list.
|
||||||
|
// AI_MODELS_CONFIG is included to clean up values written by older
|
||||||
|
// versions of the panel (it is no longer written).
|
||||||
|
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
||||||
|
const keys = new Set<string>([
|
||||||
|
"AI_MODELS_CONFIG",
|
||||||
|
"AI_PROVIDER",
|
||||||
|
"AI_MODEL",
|
||||||
|
])
|
||||||
|
const indexByProvider = new Map<ProviderName, number>()
|
||||||
|
for (const p of list) {
|
||||||
|
const index = indexByProvider.get(p.provider) ?? 0
|
||||||
|
indexByProvider.set(p.provider, index + 1)
|
||||||
|
if (p.provider === "bedrock") {
|
||||||
|
keys.add("ADMIN_AWS_ACCESS_KEY_ID")
|
||||||
|
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
|
||||||
|
keys.add("ADMIN_AWS_REGION")
|
||||||
|
// Written by older versions; listed so the next save clears them
|
||||||
|
keys.add("AWS_ACCESS_KEY_ID")
|
||||||
|
keys.add("AWS_SECRET_ACCESS_KEY")
|
||||||
|
keys.add("AWS_REGION")
|
||||||
|
} else if (p.provider === "vertexai") {
|
||||||
|
keys.add("GOOGLE_VERTEX_API_KEY")
|
||||||
|
keys.add("GOOGLE_VERTEX_BASE_URL")
|
||||||
|
} else if (p.provider === "ollama") {
|
||||||
|
keys.add("OLLAMA_API_KEY")
|
||||||
|
keys.add("OLLAMA_BASE_URL")
|
||||||
|
} else {
|
||||||
|
const env = credEnvNames(p.provider, index)
|
||||||
|
if (env.key) keys.add(env.key)
|
||||||
|
if (env.url) keys.add(env.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys]
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Declarative registry of the general env vars editable in the admin panel.
|
||||||
|
// Drives both server-side validation (app/api/admin/settings) and UI
|
||||||
|
// rendering (app/[lang]/admin). Keys are exactly the env var names.
|
||||||
|
//
|
||||||
|
// AI providers and models are managed separately in the panel's Models
|
||||||
|
// section (lib/admin/providers.ts), not here.
|
||||||
|
//
|
||||||
|
// Not listed here (and therefore rejected by the API):
|
||||||
|
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
|
||||||
|
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
||||||
|
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
||||||
|
|
||||||
|
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
||||||
|
|
||||||
|
export interface SettingDef {
|
||||||
|
key: string
|
||||||
|
group: string
|
||||||
|
type: SettingType
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
options?: string[]
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
placeholder?: string
|
||||||
|
// Built-in default applied at runtime when the value is unset, so the UI
|
||||||
|
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
|
||||||
|
default?: string
|
||||||
|
// Value is only picked up at process start (module-load readers)
|
||||||
|
restartRequired?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingGroup {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
// Optional sections gated by an on/off switch in the panel; fields are
|
||||||
|
// grayed out until enabled. Starts on when any field is already set.
|
||||||
|
toggleable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTING_GROUPS: SettingGroup[] = [
|
||||||
|
{
|
||||||
|
id: "generation",
|
||||||
|
title: "Generation",
|
||||||
|
description: "Output parameters applied to all chat requests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "access",
|
||||||
|
title: "Access Control",
|
||||||
|
description: "Restrict who can use this deployment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "features",
|
||||||
|
title: "Features",
|
||||||
|
description: "Optional features and security toggles.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "observability",
|
||||||
|
title: "Observability",
|
||||||
|
description: "Langfuse tracing for LLM calls.",
|
||||||
|
toggleable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quota",
|
||||||
|
title: "Quota & Rate Limits",
|
||||||
|
description:
|
||||||
|
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
|
||||||
|
toggleable: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SETTINGS_REGISTRY: SettingDef[] = [
|
||||||
|
// ── Generation ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "TEMPERATURE",
|
||||||
|
group: "generation",
|
||||||
|
type: "number",
|
||||||
|
label: "Temperature",
|
||||||
|
description:
|
||||||
|
"Leave unset for reasoning models that reject temperature.",
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "MAX_OUTPUT_TOKENS",
|
||||||
|
group: "generation",
|
||||||
|
type: "number",
|
||||||
|
label: "Max Output Tokens",
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Access Control ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "ACCESS_CODE_LIST",
|
||||||
|
group: "access",
|
||||||
|
type: "string",
|
||||||
|
label: "Access Codes",
|
||||||
|
description:
|
||||||
|
"Comma-separated list. Users must enter one to chat. Empty = open access.",
|
||||||
|
placeholder: "code1,code2",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Features ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "ENABLE_VLM_VALIDATION",
|
||||||
|
group: "features",
|
||||||
|
type: "boolean",
|
||||||
|
label: "VLM Diagram Validation",
|
||||||
|
description:
|
||||||
|
"Visually validate generated diagrams with a vision model.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "VALIDATION_MODEL",
|
||||||
|
group: "features",
|
||||||
|
type: "string",
|
||||||
|
label: "Validation Model",
|
||||||
|
description: "Falls back to the default AI model when empty.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "VALIDATION_TIMEOUT",
|
||||||
|
group: "features",
|
||||||
|
type: "number",
|
||||||
|
label: "Validation Timeout (ms)",
|
||||||
|
min: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ENABLE_HISTORY_XML_REPLACE",
|
||||||
|
group: "features",
|
||||||
|
type: "boolean",
|
||||||
|
label: "History XML Compression",
|
||||||
|
description: "Replace old diagram XML in history with placeholders.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ALLOW_PRIVATE_URLS",
|
||||||
|
group: "features",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Allow Private URLs",
|
||||||
|
description:
|
||||||
|
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
|
||||||
|
// Unset means allowed at runtime (ssrf-protection: !== "false")
|
||||||
|
default: "true",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Observability ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "LANGFUSE_PUBLIC_KEY",
|
||||||
|
group: "observability",
|
||||||
|
type: "string",
|
||||||
|
label: "Langfuse Public Key",
|
||||||
|
placeholder: "pk-lf-…",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "LANGFUSE_SECRET_KEY",
|
||||||
|
group: "observability",
|
||||||
|
type: "secret",
|
||||||
|
label: "Langfuse Secret Key",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "LANGFUSE_BASEURL",
|
||||||
|
group: "observability",
|
||||||
|
type: "string",
|
||||||
|
label: "Langfuse Base URL",
|
||||||
|
placeholder: "https://cloud.langfuse.com",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Quota ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: "DAILY_REQUEST_LIMIT",
|
||||||
|
group: "quota",
|
||||||
|
type: "number",
|
||||||
|
label: "Daily Request Limit",
|
||||||
|
description: "Per IP per day.",
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DAILY_TOKEN_LIMIT",
|
||||||
|
group: "quota",
|
||||||
|
type: "number",
|
||||||
|
label: "Daily Token Limit",
|
||||||
|
description: "Per IP per day.",
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "TPM_LIMIT",
|
||||||
|
group: "quota",
|
||||||
|
type: "number",
|
||||||
|
label: "Tokens Per Minute",
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DYNAMODB_QUOTA_TABLE",
|
||||||
|
group: "quota",
|
||||||
|
type: "string",
|
||||||
|
label: "DynamoDB Table",
|
||||||
|
description: "Quota enforcement is disabled when empty.",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DYNAMODB_REGION",
|
||||||
|
group: "quota",
|
||||||
|
type: "string",
|
||||||
|
label: "DynamoDB Region",
|
||||||
|
placeholder: "ap-northeast-1",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "QUOTA_TIMEZONE",
|
||||||
|
group: "quota",
|
||||||
|
type: "string",
|
||||||
|
label: "Quota Timezone",
|
||||||
|
description: "Timezone for the daily reset boundary.",
|
||||||
|
placeholder: "UTC",
|
||||||
|
restartRequired: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
|
||||||
|
SETTINGS_REGISTRY.map((def) => [def.key, def]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
|
||||||
|
SETTING_GROUPS.map((g) => [
|
||||||
|
g.id,
|
||||||
|
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
|
||||||
|
]),
|
||||||
|
)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
// File-based admin settings, overlaid onto process.env (dotenv-style).
|
||||||
|
// Precedence: settings file > env var > built-in default.
|
||||||
|
// Keys are exactly the env var names.
|
||||||
|
|
||||||
|
interface SettingsFile {
|
||||||
|
version: 1
|
||||||
|
values: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
// Original env values snapshotted before the first overlay, so removing
|
||||||
|
// a key from the settings file restores the env default. null = was unset.
|
||||||
|
originalEnv: Record<string, string | null>
|
||||||
|
// Keys currently overlaid, so we can restore ones removed from the file.
|
||||||
|
overlaidKeys: Set<string>
|
||||||
|
cachedSettings: Record<string, string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept on globalThis because the build can load this module more than once
|
||||||
|
// (instrumentation.ts and the API routes get separate copies); per-module
|
||||||
|
// state would make a route forget what instrumentation overlaid at startup.
|
||||||
|
const globalState = globalThis as typeof globalThis & {
|
||||||
|
__adminSettingsState?: SettingsState
|
||||||
|
}
|
||||||
|
globalState.__adminSettingsState ??= {
|
||||||
|
originalEnv: {},
|
||||||
|
overlaidKeys: new Set(),
|
||||||
|
cachedSettings: null,
|
||||||
|
}
|
||||||
|
const state = globalState.__adminSettingsState
|
||||||
|
|
||||||
|
export function getSettingsPath(): string {
|
||||||
|
const custom = process.env.SETTINGS_FILE
|
||||||
|
if (custom && custom.trim().length > 0) return custom
|
||||||
|
return path.join(process.cwd(), "data", "settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings(): Record<string, string> {
|
||||||
|
if (state.cachedSettings) return state.cachedSettings
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(getSettingsPath(), "utf8")
|
||||||
|
const parsed = JSON.parse(raw) as SettingsFile
|
||||||
|
// Keep only string values — a hand-edited or corrupted file could
|
||||||
|
// hold null/arrays/numbers that would otherwise be overlaid onto
|
||||||
|
// process.env and coerce to junk like "[object Object]".
|
||||||
|
const values: Record<string, string> = {}
|
||||||
|
const rawValues =
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.values === "object" &&
|
||||||
|
parsed.values &&
|
||||||
|
!Array.isArray(parsed.values)
|
||||||
|
? parsed.values
|
||||||
|
: {}
|
||||||
|
for (const [key, value] of Object.entries(rawValues)) {
|
||||||
|
if (typeof value === "string") values[key] = value
|
||||||
|
}
|
||||||
|
state.cachedSettings = values
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== "ENOENT") {
|
||||||
|
console.error("[admin-settings] Failed to read settings file:", err)
|
||||||
|
}
|
||||||
|
state.cachedSettings = {}
|
||||||
|
}
|
||||||
|
return state.cachedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyToEnv(): void {
|
||||||
|
const values = loadSettings()
|
||||||
|
const { originalEnv } = state
|
||||||
|
|
||||||
|
// Restore env for keys that were overlaid before but are now gone
|
||||||
|
for (const key of state.overlaidKeys) {
|
||||||
|
if (!(key in values)) {
|
||||||
|
const original = originalEnv[key]
|
||||||
|
if (original === null) delete process.env[key]
|
||||||
|
else process.env[key] = original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
originalEnv[key] = process.env[key] ?? null
|
||||||
|
}
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
state.overlaidKeys = new Set(Object.keys(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The effective env value if the file entry were removed (for fallback display)
|
||||||
|
export function getEnvFallback(key: string): string | null {
|
||||||
|
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
|
||||||
|
return process.env[key] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a key's current value comes from the file, the environment, or is unset
|
||||||
|
export function getValueSource(key: string): "file" | "env" | "default" {
|
||||||
|
if (key in loadSettings()) return "file"
|
||||||
|
return getEnvFallback(key) !== null ? "env" : "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(updates: Record<string, string | null>): void {
|
||||||
|
const current = { ...loadSettings() }
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === null) delete current[key]
|
||||||
|
else current[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getSettingsPath()
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||||
|
const tmpPath = `${filePath}.tmp`
|
||||||
|
const data: SettingsFile = { version: 1, values: current }
|
||||||
|
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||||
|
fs.renameSync(tmpPath, filePath)
|
||||||
|
|
||||||
|
state.cachedSettings = current
|
||||||
|
applyToEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
let writableCache: boolean | null = null
|
||||||
|
|
||||||
|
export function isSettingsWritable(): boolean {
|
||||||
|
if (writableCache !== null) return writableCache
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(getSettingsPath())
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK)
|
||||||
|
writableCache = true
|
||||||
|
} catch {
|
||||||
|
writableCache = false
|
||||||
|
}
|
||||||
|
return writableCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: reset module state
|
||||||
|
export function _resetForTests(): void {
|
||||||
|
state.cachedSettings = null
|
||||||
|
writableCache = null
|
||||||
|
for (const key of state.overlaidKeys) {
|
||||||
|
const original = state.originalEnv[key]
|
||||||
|
if (original === null) delete process.env[key]
|
||||||
|
else if (original !== undefined) process.env[key] = original
|
||||||
|
}
|
||||||
|
state.overlaidKeys = new Set()
|
||||||
|
state.originalEnv = {}
|
||||||
|
}
|
||||||
+819
-508
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
export interface CachedResponse {
|
export interface CachedResponse {
|
||||||
promptText: string
|
promptText: string
|
||||||
hasImage: boolean
|
hasImage: boolean
|
||||||
|
// Name of the bundled example file the prompt is sent with
|
||||||
|
fileName?: string
|
||||||
xml: string
|
xml: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Replicate this in aws style",
|
promptText: "Replicate this in aws style",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
|
fileName: "architecture.png",
|
||||||
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
||||||
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
@@ -318,6 +321,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Replicate this flowchart.",
|
promptText: "Replicate this flowchart.",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
|
fileName: "example.png",
|
||||||
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||||
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
@@ -379,6 +383,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Summarize this paper as a diagram",
|
promptText: "Summarize this paper as a diagram",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
|
fileName: "chain-of-thought.txt",
|
||||||
xml: `<mxCell id="title_bg" parent="1"
|
xml: `<mxCell id="title_bg" parent="1"
|
||||||
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
|
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
|
||||||
value="" vertex="1">
|
value="" vertex="1">
|
||||||
@@ -879,14 +884,19 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Examples that come with a file only match when that exact example file is
|
||||||
|
// attached, so a user's own file with the same prompt still goes to the model.
|
||||||
|
// Callers that can't tell file names (the server) only get text-only examples.
|
||||||
export function findCachedResponse(
|
export function findCachedResponse(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
hasImage: boolean,
|
hasImage: boolean,
|
||||||
|
fileName?: string,
|
||||||
): CachedResponse | undefined {
|
): CachedResponse | undefined {
|
||||||
return CACHED_EXAMPLE_RESPONSES.find(
|
return CACHED_EXAMPLE_RESPONSES.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.promptText === promptText &&
|
c.promptText === promptText &&
|
||||||
c.hasImage === hasImage &&
|
c.hasImage === hasImage &&
|
||||||
|
(!c.fileName || c.fileName === fileName) &&
|
||||||
c.xml !== "",
|
c.xml !== "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-45
@@ -6,25 +6,37 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
|||||||
export const MAX_FILES = 5
|
export const MAX_FILES = 5
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
// Helper function to validate file parts in messages
|
||||||
|
// Checks every message, since history is sent to the model too
|
||||||
export function validateFileParts(messages: any[]): {
|
export function validateFileParts(messages: any[]): {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
} {
|
} {
|
||||||
const lastMessage = messages[messages.length - 1]
|
for (const message of messages) {
|
||||||
const fileParts =
|
const fileParts =
|
||||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
message?.parts?.filter((p: any) => p.type === "file") || []
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
if (fileParts.length > MAX_FILES) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
for (const filePart of fileParts) {
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
// The client sends files inline. Any other URL would be downloaded
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
// by the server (AI SDK does that for models without URL support).
|
||||||
if (filePart.url?.startsWith("data:")) {
|
if (
|
||||||
|
typeof filePart.url !== "string" ||
|
||||||
|
!filePart.url.startsWith("data:")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Files must be uploaded inline as data URLs.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
const base64Data = filePart.url.split(",")[1]
|
const base64Data = filePart.url.split(",")[1]
|
||||||
if (base64Data) {
|
if (base64Data) {
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
@@ -41,49 +53,83 @@ export function validateFileParts(messages: any[]): {
|
|||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to check if diagram is minimal/empty
|
// A tool-call input providers accept: a non-empty JSON object
|
||||||
export function isMinimalDiagram(xml: string): boolean {
|
function isValidToolInput(input: unknown): boolean {
|
||||||
const stripped = xml.replace(/\s/g, "")
|
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
||||||
return !stripped.includes('id="2"')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
// 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)
|
// 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
|
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
|
||||||
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||||
return messages.map((msg) => {
|
return messages.map((msg) => {
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
const replacedContent = msg.content
|
const replacedContent = msg.content.map((part: any) => {
|
||||||
.map((part: any) => {
|
if (
|
||||||
if (part.type === "tool-call") {
|
part.type === "tool-call" &&
|
||||||
const toolName = part.toolName
|
isValidToolInput(part.input) &&
|
||||||
// Fix invalid/undefined inputs from interrupted streaming
|
(part.toolName === "display_diagram" ||
|
||||||
if (
|
part.toolName === "edit_diagram")
|
||||||
!part.input ||
|
) {
|
||||||
typeof part.input !== "object" ||
|
return {
|
||||||
Object.keys(part.input).length === 0
|
...part,
|
||||||
) {
|
input: {
|
||||||
// Skip tool calls with invalid inputs entirely
|
placeholder:
|
||||||
return null
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
}
|
},
|
||||||
if (
|
|
||||||
toolName === "display_diagram" ||
|
|
||||||
toolName === "edit_diagram"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
input: {
|
|
||||||
placeholder:
|
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return part
|
}
|
||||||
})
|
return part
|
||||||
.filter(Boolean) // Remove null entries (invalid tool calls)
|
})
|
||||||
return { ...msg, content: replacedContent }
|
return { ...msg, content: replacedContent }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
|
||||||
|
// together with their tool-results: providers reject a result whose call is missing.
|
||||||
|
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
||||||
|
export function dropInvalidToolCalls(messages: any[]): any[] {
|
||||||
|
const droppedIds = new Set<string>()
|
||||||
|
return messages
|
||||||
|
.map((msg) => {
|
||||||
|
if (!Array.isArray(msg.content)) return msg
|
||||||
|
const content = msg.content.filter((part: any) => {
|
||||||
|
if (
|
||||||
|
msg.role === "assistant" &&
|
||||||
|
part.type === "tool-call" &&
|
||||||
|
!isValidToolInput(part.input)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`[chat-helpers] Dropping tool-call with invalid input:`,
|
||||||
|
{ toolName: part.toolName, input: part.input },
|
||||||
|
)
|
||||||
|
droppedIds.add(part.toolCallId)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Results always come after their call, so the id is known by now
|
||||||
|
return !(
|
||||||
|
part.type === "tool-result" &&
|
||||||
|
droppedIds.has(part.toolCallId)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return { ...msg, content }
|
||||||
|
})
|
||||||
|
.filter((msg) => !Array.isArray(msg.content) || msg.content.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix common LLM JSON mistakes in tool-call input before jsonrepair runs
|
||||||
|
export function fixToolInputJson(input: string): string {
|
||||||
|
return (
|
||||||
|
input
|
||||||
|
// Inconsistent quote escaping in XML attributes inside JSON strings:
|
||||||
|
// y="-20\" (opening quote unescaped, closing escaped) becomes y=\"-20\".
|
||||||
|
// Must run before the key fix below, which would rewrite the `="`.
|
||||||
|
.replace(/(\w+)="([^"]*?)\\"/g, '$1=\\"$2\\"')
|
||||||
|
// `:=` instead of `: `
|
||||||
|
.replace(/:=/g, ": ")
|
||||||
|
// `"key"= "` instead of `"key": "`, only for JSON keys
|
||||||
|
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { wrapLanguageModel } from "ai"
|
||||||
|
import { rejectionText } from "@/lib/output-token-limit"
|
||||||
|
|
||||||
|
type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude 4.7 and later answer a non-default temperature, top_p or top_k,
|
||||||
|
* and the extended thinking budget (thinking type "enabled"), with a 400.
|
||||||
|
* TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these.
|
||||||
|
*/
|
||||||
|
const DEPRECATED_PARAM =
|
||||||
|
/`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i
|
||||||
|
|
||||||
|
interface CallParams {
|
||||||
|
temperature?: number
|
||||||
|
topP?: number
|
||||||
|
topK?: number
|
||||||
|
providerOptions?: Record<string, Record<string, unknown> | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
// What these models take instead of a budget. Without display "summarized"
|
||||||
|
// they think but send no thinking text to show.
|
||||||
|
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
|
||||||
|
|
||||||
|
/** Turn a thinking config of type "enabled" stored under key into adaptive */
|
||||||
|
function adaptiveThinking(
|
||||||
|
options: Record<string, unknown> | undefined,
|
||||||
|
key: string,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const config = options?.[key] as { type?: string } | undefined
|
||||||
|
if (config?.type !== "enabled") return options
|
||||||
|
return { ...options, [key]: ADAPTIVE_THINKING }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The params without the settings newer Claude models reject, or null when
|
||||||
|
* the error is about something else or there is nothing to change. The
|
||||||
|
* model then runs with its default sampling, and a thinking budget becomes
|
||||||
|
* adaptive thinking.
|
||||||
|
*/
|
||||||
|
export function withoutDeprecatedParams<T extends CallParams>(
|
||||||
|
error: unknown,
|
||||||
|
params: T,
|
||||||
|
): T | null {
|
||||||
|
const text = rejectionText(error)
|
||||||
|
if (!text || !DEPRECATED_PARAM.test(text)) return null
|
||||||
|
|
||||||
|
const { temperature, topP, topK, ...rest } = params
|
||||||
|
const options = params.providerOptions
|
||||||
|
const anthropic = adaptiveThinking(options?.anthropic, "thinking")
|
||||||
|
const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig")
|
||||||
|
const changed =
|
||||||
|
temperature !== undefined ||
|
||||||
|
topP !== undefined ||
|
||||||
|
topK !== undefined ||
|
||||||
|
anthropic !== options?.anthropic ||
|
||||||
|
bedrock !== options?.bedrock
|
||||||
|
if (!changed) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(options && {
|
||||||
|
providerOptions: {
|
||||||
|
...options,
|
||||||
|
...(anthropic && { anthropic }),
|
||||||
|
...(bedrock && { bedrock }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry the stream once without the settings newer Claude models reject. */
|
||||||
|
export function withDeprecatedParamsFallback(
|
||||||
|
model: WrappedModel,
|
||||||
|
): WrappedModel {
|
||||||
|
return wrapLanguageModel({
|
||||||
|
model,
|
||||||
|
middleware: {
|
||||||
|
specificationVersion: "v3",
|
||||||
|
async wrapStream({ doStream, params, model: inner }) {
|
||||||
|
try {
|
||||||
|
return await doStream()
|
||||||
|
} catch (error) {
|
||||||
|
const retry = withoutDeprecatedParams(error, params)
|
||||||
|
if (!retry) throw error
|
||||||
|
console.warn(
|
||||||
|
"[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking",
|
||||||
|
)
|
||||||
|
return await inner.doStream(retry)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user