mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 01:07:56 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e598891322 |
+3
-49
@@ -20,61 +20,15 @@ npm run lint # Check lint errors
|
||||
npm run check # Run all checks (CI)
|
||||
```
|
||||
|
||||
Git hooks via Husky run automatically:
|
||||
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
||||
- **Pre-push**: Unit tests
|
||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||
|
||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests before submitting PRs:
|
||||
|
||||
```bash
|
||||
npm run test # Unit tests (Vitest)
|
||||
npm run test:e2e # E2E tests (Playwright)
|
||||
```
|
||||
|
||||
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
|
||||
|
||||
To run a specific test file:
|
||||
```bash
|
||||
npx playwright test tests/e2e/diagram-generation.spec.ts
|
||||
```
|
||||
|
||||
To run tests with UI mode:
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## Before You Start
|
||||
|
||||
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make changes (pre-commit runs lint + type check automatically)
|
||||
3. Run E2E tests with `npm run test:e2e`
|
||||
4. Push (pre-push runs unit tests automatically)
|
||||
5. Submit PR against `main` with a clear description
|
||||
|
||||
CI will run the full test suite on your PR.
|
||||
|
||||
## Using AI Tools
|
||||
|
||||
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
|
||||
|
||||
1. **Review the code** — understand what was generated, don't just commit blindly
|
||||
2. **Write a PR description** — explain what changed and why
|
||||
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
|
||||
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
|
||||
|
||||
## Code Review
|
||||
|
||||
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||
- **Valid suggestions**: Please address them in your code.
|
||||
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
|
||||
2. Make changes and ensure `npm run check` passes
|
||||
3. Submit PR against `main` with a clear description
|
||||
|
||||
## Issues
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["after 10am on the first day of the month"],
|
||||
"schedule": ["after 10am on saturday"],
|
||||
"timezone": "Asia/Tokyo",
|
||||
"packageRules": [
|
||||
{
|
||||
@@ -13,7 +13,6 @@
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "major dependencies",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
@@ -33,11 +32,6 @@
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
||||
@@ -23,9 +23,7 @@ jobs:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Biome format
|
||||
# Pin to the version in package.json so CI matches local/pre-commit
|
||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
|
||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
|
||||
@@ -40,3 +40,5 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Security audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
|
||||
@@ -58,8 +58,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -91,3 +89,4 @@ jobs:
|
||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ on:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
# Mac and Linux: Build and publish directly (no signing needed)
|
||||
build-mac-linux:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -21,9 +20,13 @@ jobs:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -34,80 +37,10 @@ jobs:
|
||||
node-version: 24
|
||||
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
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build and publish
|
||||
- name: Build and publish Electron app
|
||||
run: npm run dist:${{ matrix.platform }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Windows: Build, sign with SignPath, then publish
|
||||
build-windows:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
rm -rf public/drawio/META-INF
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
# Build WITHOUT publishing
|
||||
- name: Build Windows app
|
||||
run: npm run dist:win:build
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload unsigned artifacts for signing
|
||||
uses: actions/upload-artifact@v6
|
||||
id: upload-unsigned
|
||||
with:
|
||||
name: windows-unsigned
|
||||
path: release/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign with SignPath
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||
project-slug: 'next-ai-draw-io'
|
||||
signing-policy-slug: 'release-signing'
|
||||
artifact-configuration-slug: 'windows-exe'
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: release-signed
|
||||
|
||||
- name: Upload signed artifacts to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-signed/*.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token for npm trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: publish-mcp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/mcp-server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Check if version is already published
|
||||
id: version
|
||||
run: |
|
||||
LOCAL=$(node -p "require('./package.json').version")
|
||||
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version ${LOCAL} not on npm - publishing"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Build and check package contents
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm run build && npm run check-package
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-unit:
|
||||
name: Lint & Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run check
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
# Tests run from src/, so check the built npm package separately
|
||||
- name: Build MCP server and check package contents
|
||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright deps (cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
+1
-14
@@ -14,8 +14,6 @@ packages/*/dist
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -56,8 +54,6 @@ push-via-ec2.sh
|
||||
/dist-electron/
|
||||
/release/
|
||||
/electron-standalone/
|
||||
# Draw.io static files (downloaded during CI build)
|
||||
public/drawio/
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
@@ -69,13 +65,4 @@ CLAUDE.md
|
||||
.spec-workflow
|
||||
|
||||
# edgeone
|
||||
.edgeone
|
||||
opencode.json
|
||||
ai-models.json
|
||||
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
.edgeone
|
||||
@@ -1,2 +1 @@
|
||||
npx lint-staged
|
||||
npx tsc --noEmit
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Skip if node_modules not installed (e.g., on EC2 push server)
|
||||
if [ -d "node_modules" ]; then
|
||||
npm run test -- --run
|
||||
fi
|
||||
-13
@@ -9,7 +9,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Build application
|
||||
@@ -31,15 +30,6 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||
|
||||
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
|
||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||
|
||||
# Control sponsorship and self-hosting messaging in quota notifications.
|
||||
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
|
||||
ARG NEXT_PUBLIC_SELFHOSTED=""
|
||||
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
|
||||
|
||||
# Build Next.js application (standalone mode)
|
||||
RUN npm run build
|
||||
|
||||
@@ -61,9 +51,6 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Writable dir for admin panel settings (data/settings.json)
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -19,18 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
|
||||
> 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).
|
||||
> 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!
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
@@ -42,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [MCP Server (Preview)](#mcp-server-preview)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
@@ -51,14 +40,11 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Installation](#installation)
|
||||
- [Deployment](#deployment)
|
||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||
- [Deploy on Vercel](#deploy-on-vercel)
|
||||
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||
- [Multi-Provider Support](#multi-provider-support)
|
||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Support \& Contact](#support--contact)
|
||||
- [FAQ](#faq)
|
||||
- [Star History](#star-history)
|
||||
|
||||
## Examples
|
||||
@@ -76,24 +62,24 @@ Here are some example prompts and their generated diagrams:
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>RAG Technique Diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||
<strong>GCP architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Authentication using React and AWS</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||
<strong>AWS architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Open Innovation</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||
<strong>Azure architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Cat sketch</strong><br />
|
||||
<strong>Cat sketch prompt</strong><br />
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||
</td>
|
||||
@@ -112,7 +98,9 @@ Here are some example prompts and their generated diagrams:
|
||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
|
||||
## MCP Server
|
||||
## MCP Server (Preview)
|
||||
|
||||
> **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).
|
||||
|
||||
@@ -138,13 +126,6 @@ Then ask Claude to create diagrams:
|
||||
|
||||
The diagram appears in your browser in real-time!
|
||||
|
||||
The MCP server includes most of the web app's drawing features:
|
||||
|
||||
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
||||
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
||||
|
||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||
|
||||
## Getting Started
|
||||
@@ -204,7 +185,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
|
||||
|
||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||
|
||||
### Deploy on Vercel
|
||||
### Deploy on Vercel (Recommended)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
@@ -220,38 +201,24 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
|
||||
## Multi-Provider Support
|
||||
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
||||
- [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)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Google Vertex AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||
|
||||
### Server-Side Multi-Model Configuration
|
||||
|
||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
||||
|
||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
||||
|
||||
**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.
|
||||
@@ -270,9 +237,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
|
||||
## Support & Contact
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
|
||||
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
**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!
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
@@ -280,10 +245,6 @@ For support or inquiries, please open an issue on the GitHub repository or conta
|
||||
|
||||
- Email: me[at]jiang.jp
|
||||
|
||||
## FAQ
|
||||
|
||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
+105
-91
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function AboutCN() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -78,7 +89,7 @@ export default function AboutCN() {
|
||||
<p>
|
||||
好消息!感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -87,7 +98,7 @@ export default function AboutCN() {
|
||||
</a>
|
||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
@@ -97,21 +108,40 @@ export default function AboutCN() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite Poster */}
|
||||
<div className="text-center mb-5">
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/volcengine-invite.png"
|
||||
alt="火山引擎方舟 Coding Plan"
|
||||
width={300}
|
||||
height={400}
|
||||
className="mx-auto rounded-lg"
|
||||
/>
|
||||
</a>
|
||||
{/* Usage Limits */}
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
当前使用限制:
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyRequestLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
请求/天
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Token/天
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(tpmLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Token/分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
@@ -175,106 +205,92 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50模型架构动画
|
||||
动画Transformer连接器
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
<strong>提示词:</strong> 给我一个带有
|
||||
<strong>动画连接器</strong>的Transformer架构图。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50模型架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="带动画连接器的Transformer架构"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技术图
|
||||
GCP架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>GCP图标</strong>
|
||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
React和AWS认证流程
|
||||
AWS架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>AWS图标</strong>
|
||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="认证架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
敏捷Scrum流程
|
||||
Azure架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>Azure图标</strong>
|
||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="敏捷Scrum流程图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
开放式创新
|
||||
猫咪素描
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
<strong>提示词:</strong>{" "}
|
||||
给我画一只可爱的猫。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="开放式创新图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫咪绘图"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,7 +324,7 @@ export default function AboutCN() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -323,13 +339,11 @@ export default function AboutCN() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
@@ -343,7 +357,7 @@ export default function AboutCN() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
特别感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
+108
-76
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
||||
],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function AboutJA() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -86,7 +97,7 @@ export default function AboutJA() {
|
||||
<p>
|
||||
朗報です!
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -95,7 +106,7 @@ export default function AboutJA() {
|
||||
</a>
|
||||
様のご支援により、デモサイトでは強力な{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
@@ -105,6 +116,42 @@ export default function AboutJA() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Limits */}
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
現在の使用制限:
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyRequestLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
リクエスト/日
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
トークン/日
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(tpmLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
トークン/分
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
@@ -168,106 +215,93 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50モデルアーキテクチャアニメーション
|
||||
アニメーションTransformerコネクタ
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
付きのTransformerアーキテクチャ図を作成してください。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50モデルアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技術ダイアグラム
|
||||
GCPアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>GCPアイコン</strong>
|
||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAGアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCPアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ReactとAWSによる認証
|
||||
AWSアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>AWSアイコン</strong>
|
||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="認証アーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWSアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アジャイルスクラムプロセス
|
||||
Azureアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>Azureアイコン</strong>
|
||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="アジャイルスクラム図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azureアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
オープンイノベーション
|
||||
猫のスケッチ
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
かわいい猫を描いてください。
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="オープンイノベーション図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫の絵"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +339,7 @@ export default function AboutJA() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -320,13 +354,11 @@ export default function AboutJA() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
@@ -340,7 +372,7 @@ export default function AboutJA() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
+110
-75
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
||||
],
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return `${num / 1000}k`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -87,7 +98,7 @@ export default function About() {
|
||||
Great news! Thanks to the generous
|
||||
sponsorship from{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -96,7 +107,7 @@ export default function About() {
|
||||
</a>
|
||||
, the demo site now uses the powerful{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
model for better diagram generation! Sign up
|
||||
via the link to get{" "}
|
||||
@@ -107,6 +118,42 @@ export default function About() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Limits */}
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Please note the current usage limits:
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyRequestLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
requests/day
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
tokens/day
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||
<p className="text-lg font-bold text-amber-600">
|
||||
{formatNumber(tpmLimit)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
tokens/min
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
@@ -182,106 +229,96 @@ export default function About() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated ResNet50 Model Architecture
|
||||
Animated Transformer Connectors
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
<strong>animated connector</strong> diagram of
|
||||
transformer's architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="Architecture diagram for ResNet50 model"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="Transformer Architecture with Animated Connectors"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
{/* Cloud Architecture Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG Technique Diagram
|
||||
GCP Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
<strong>Prompt:</strong> Generate a GCP
|
||||
architecture diagram with{" "}
|
||||
<strong>GCP icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Authentication using React and AWS
|
||||
AWS Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
<strong>Prompt:</strong> Generate an AWS
|
||||
architecture diagram with{" "}
|
||||
<strong>AWS icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="Authentication Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Agile Scrum Process
|
||||
Azure Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
<strong>Prompt:</strong> Generate an Azure
|
||||
architecture diagram with{" "}
|
||||
<strong>Azure icons</strong>. Users connect
|
||||
to a frontend hosted on an instance.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="Agile Scrum Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Open Innovation
|
||||
Cat Sketch
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
<strong>Prompt:</strong> Draw a cute cat for
|
||||
me.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="Open Innovation Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="Cat Drawing"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +358,7 @@ export default function About() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -336,13 +373,11 @@ export default function About() {
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
@@ -358,7 +393,7 @@ export default function About() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
Special thanks to{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────
|
||||
|
||||
export type SecretValue = { isSet: true; hint: string }
|
||||
|
||||
export function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
}
|
||||
|
||||
export interface SettingState {
|
||||
key: string
|
||||
source: "file" | "env" | "default"
|
||||
value: string | SecretValue | null
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, SettingState>
|
||||
|
||||
// Editable text of a saved setting; secrets have none (write-only)
|
||||
export function savedTextOf(state: SettingState | undefined): string {
|
||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
}
|
||||
|
||||
// Admin provider in client state. Secret fields hold either a masked
|
||||
// marker (unchanged) or a plaintext string (new value).
|
||||
export interface AdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string | SecretValue
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string | SecretValue
|
||||
awsSecretAccessKey?: string | SecretValue
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string | SecretValue
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||
export interface EnvProvider {
|
||||
name: string
|
||||
provider: ProviderName
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
||||
const res = await fetch(getApiEndpoint(path), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-admin-password": pw,
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Loader2,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
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",
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: data.valid
|
||||
? {
|
||||
ok: true,
|
||||
message: formatMessage(dict.admin.testOk, {
|
||||
ms: data.responseTime,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: data.error || dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
ok: false,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">{info.label}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.models.length === 0
|
||||
? dict.admin.noModelsConfigured
|
||||
: formatMessage(
|
||||
provider.models.length === 1
|
||||
? dict.admin.modelCount
|
||||
: dict.admin.modelCountPlural,
|
||||
{ count: provider.models.length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
provider.isDefault &&
|
||||
"fill-amber-400 text-amber-400",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.default}
|
||||
<Switch
|
||||
checked={!!provider.isDefault}
|
||||
disabled={disabled}
|
||||
aria-label={dict.admin.setAsDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ isDefault: checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
{dict.admin.delete}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||
<ProviderCredentialsFields
|
||||
provider={provider.provider}
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
disabled={disabled}
|
||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||
renderSecret={({ field, id }) => (
|
||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||
// associated; only one ProviderDetail is mounted at a time.
|
||||
<SecretInput
|
||||
id={id}
|
||||
keepOnEmpty
|
||||
value={provider[field]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onUpdate({ [field]: v })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{dict.admin.models}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={modelInput}
|
||||
disabled={disabled}
|
||||
placeholder={dict.admin.modelIdPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 w-48 font-mono text-xs"
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addModel(modelInput)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={disabled || !modelInput.trim()}
|
||||
aria-label={dict.admin.addModel}
|
||||
onClick={() => addModel(modelInput)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 text-xs">
|
||||
{dict.admin.suggested}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{suggestions.map((m) => (
|
||||
<SelectItem
|
||||
key={m}
|
||||
value={m}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
{provider.models.length === 0 ? (
|
||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.addProviderToOfferModels}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{provider.models.map((modelId, index) => {
|
||||
const result = testResults[modelId]
|
||||
return (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{provider.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{result && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
result.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-48 truncate">
|
||||
{result.message}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={
|
||||
disabled || testing !== null
|
||||
}
|
||||
onClick={() =>
|
||||
void testModel(modelId)
|
||||
}
|
||||
>
|
||||
{testing === modelId ? (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Zap
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{dict.admin.test}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.removeModel,
|
||||
{ model: modelId },
|
||||
)}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
models: provider.models.filter(
|
||||
(m) => m !== modelId,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||
name: provider.name || info.label,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.admin.deleteProviderDesc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.admin.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{dict.admin.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
envHasDefaultModel,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
envHasDefaultModel: boolean
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
providers[0]?.id ?? null,
|
||||
)
|
||||
const selected = providers.find((p) => p.id === selectedId)
|
||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||
id: generateId(),
|
||||
provider,
|
||||
models: [],
|
||||
// Only the very first provider becomes the default, and only when
|
||||
// the env config has no default that it would replace on save
|
||||
isDefault:
|
||||
providers.length === 0 &&
|
||||
!envProviders.some((p) => p.isDefault) &&
|
||||
!envHasDefaultModel,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
}
|
||||
|
||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||
onChange(
|
||||
providers.map((p) => {
|
||||
if (p.id !== id) {
|
||||
// Only one default at a time
|
||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||
}
|
||||
return { ...p, ...patch }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const deleteProvider = (id: string) => {
|
||||
const next = providers.filter((p) => p.id !== id)
|
||||
onChange(next)
|
||||
setSelectedId(next[0]?.id ?? null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||
{/* Provider list */}
|
||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||
<div className="flex-1 space-y-1 p-2">
|
||||
{providers.length === 0 && envProviders.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{dict.admin.addProviderHint}
|
||||
</p>
|
||||
)}
|
||||
{envProviders.map((p) => (
|
||||
<button
|
||||
key={`env:${p.name}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === `env:${p.name}` &&
|
||||
"bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{dict.admin.sourceEnv}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === p.id && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
{/* Always empty so picking the same type again still fires */}
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Plus
|
||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
return (
|
||||
<SelectItem
|
||||
key={p}
|
||||
value={p}
|
||||
disabled={envBlocked}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
dict.admin
|
||||
.managedViaEnv
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
{selected ? (
|
||||
<ProviderDetail
|
||||
key={selected.id}
|
||||
provider={selected}
|
||||
disabled={disabled}
|
||||
password={password}
|
||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||
onDelete={() => deleteProvider(selected.id)}
|
||||
/>
|
||||
) : selectedEnv ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={selectedEnv.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">
|
||||
{selectedEnv.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.envReadOnly}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<ul className="divide-y">
|
||||
{selectedEnv.models.map((modelId, index) => (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{selectedEnv.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.selectProviderHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_BY_GROUP,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
isSecretValue,
|
||||
SESSION_PASSWORD_KEY,
|
||||
type SettingState,
|
||||
type SettingsMap,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
import { ModelsSection } from "./models-section"
|
||||
import { SettingField } from "./setting-field"
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
// For each toggleable group, whether any of its settings has a value (from
|
||||
// the settings file or the environment)
|
||||
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable) continue
|
||||
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
const groupText = (id: string) =>
|
||||
(
|
||||
dict.admin.groups as Record<
|
||||
string,
|
||||
{ title: string; description: string } | undefined
|
||||
>
|
||||
)[id]
|
||||
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||
id,
|
||||
title:
|
||||
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||
}))
|
||||
const [password, setPassword] = useState("")
|
||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const [writable, setWritable] = useState(true)
|
||||
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
// General settings state
|
||||
const [settings, setSettings] = useState<SettingsMap>({})
|
||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{
|
||||
ok: boolean
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [activeGroup, setActiveGroup] = useState("models")
|
||||
|
||||
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||
|
||||
const applySettingsResponse = useCallback(
|
||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||
setWritable(data.writable)
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// A group stays on while it still has values (e.g. from env vars
|
||||
// that saving can't remove); a user's explicit "on" for a group
|
||||
// with no values yet is kept across saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = groupsWithValues(map)
|
||||
for (const id of Object.keys(next)) {
|
||||
next[id] = next[id] || !!prev[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyProvidersResponse = useCallback(
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
envHasDefaultModel?: boolean
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const login = useCallback(
|
||||
async (pw: string) => {
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
try {
|
||||
const [settingsData, providersData] = await Promise.all([
|
||||
adminFetch("/api/admin/settings", pw),
|
||||
adminFetch("/api/admin/providers", pw),
|
||||
])
|
||||
applySettingsResponse(settingsData)
|
||||
applyProvidersResponse(providersData)
|
||||
setAuthedPassword(pw)
|
||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||
} catch (err) {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||
)
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
},
|
||||
[applySettingsResponse, applyProvidersResponse, dict],
|
||||
)
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||
if (stored) void login(stored)
|
||||
}, [login])
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
const hasDirty = dirtyCount > 0
|
||||
useEffect(() => {
|
||||
if (!hasDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
// Some browsers only show the prompt when returnValue is set
|
||||
e.returnValue = ""
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [hasDirty])
|
||||
|
||||
// Highlight the section currently in view in the sidebar
|
||||
useEffect(() => {
|
||||
if (!authedPassword) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
)
|
||||
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: "-10% 0px -50% 0px" },
|
||||
)
|
||||
for (const id of NAV_GROUP_IDS) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
// value undefined drops the pending change (back to the saved value)
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null | undefined) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPending((prev) => {
|
||||
const state = settings[key]
|
||||
const isRevert =
|
||||
value !== null &&
|
||||
state?.source === "file" &&
|
||||
!isSecretValue(state?.value) &&
|
||||
value === savedTextOf(state)
|
||||
const isNoop =
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (value === undefined || isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
return { ...prev, [key]: value === "" ? null : value }
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
// Toggling a group off stages deletion of its saved values so the
|
||||
// feature actually turns off on save; toggling on drops those deletions.
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupId: string, enabled: boolean) => {
|
||||
setSaveMessage(null)
|
||||
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||
(d) => d.key,
|
||||
)
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion of saved values; drop unsaved input
|
||||
if (settings[key]?.source === "default")
|
||||
delete next[key]
|
||||
else next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!authedPassword || dirtyCount === 0) return
|
||||
setSaving(true)
|
||||
setSaveMessage(null)
|
||||
setErrors({})
|
||||
try {
|
||||
if (providersDirty) {
|
||||
const data = await adminFetch(
|
||||
"/api/admin/providers",
|
||||
authedPassword,
|
||||
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||
)
|
||||
applyProvidersResponse(data)
|
||||
}
|
||||
if (Object.keys(pending).length > 0) {
|
||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-password": authedPassword,
|
||||
},
|
||||
body: JSON.stringify({ values: pending }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
// Per-field validation errors come back as {errors: {...}}
|
||||
if (data.errors) {
|
||||
setErrors(data.errors)
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||
throw new Error(dict.admin.invalidSettings)
|
||||
}
|
||||
throw new Error(
|
||||
data.error || `Request failed (${res.status})`,
|
||||
)
|
||||
}
|
||||
applySettingsResponse(data)
|
||||
setPending({})
|
||||
}
|
||||
setSaveMessage({
|
||||
ok: true,
|
||||
text: dict.admin.saved,
|
||||
})
|
||||
setTimeout(() => setSaveMessage(null), 4000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
ok: false,
|
||||
text:
|
||||
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
authedPassword,
|
||||
pending,
|
||||
providers,
|
||||
providersDirty,
|
||||
dirtyCount,
|
||||
applySettingsResponse,
|
||||
applyProvidersResponse,
|
||||
dict,
|
||||
])
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────
|
||||
if (!authedPassword) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<form
|
||||
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void login(password)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dict.admin.loginPrompt}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-password">
|
||||
{dict.admin.password}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
name="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-destructive",
|
||||
!authError && "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{authError}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={authLoading}
|
||||
>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.signingIn}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.signIn
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings screen ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck
|
||||
className="h-5 w-5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.precedence}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!writable && (
|
||||
<div className="border-b bg-amber-500/10">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.notWritable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||
<nav
|
||||
aria-label={dict.admin.settingGroups}
|
||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={
|
||||
activeGroup === item.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
activeGroup === item.id
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-24">
|
||||
{/* Models section */}
|
||||
<section aria-labelledby="models" className="mb-10">
|
||||
<h2
|
||||
id="models"
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{dict.admin.models}
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{dict.admin.modelsDescription}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
envHasDefaultModel={envHasDefaultModel}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
setSaveMessage(null)
|
||||
setProviders(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registry-driven groups */}
|
||||
{SETTING_GROUPS.map((group) => {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
// Values from env vars can't be removed here, so the
|
||||
// group can't be turned off from the panel
|
||||
const envLocked = defs.some(
|
||||
(d) => settings[d.key]?.source === "env",
|
||||
)
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
aria-labelledby={group.id}
|
||||
className="mb-10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2
|
||||
id={group.id}
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
title={
|
||||
envLocked
|
||||
? dict.admin.sourceEnvTitle
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{enabledGroups[group.id]
|
||||
? dict.admin.enabled
|
||||
: dict.admin.disabled}
|
||||
<Switch
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={
|
||||
!writable ||
|
||||
saving ||
|
||||
envLocked
|
||||
}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleGroupToggle(
|
||||
group.id,
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{gt?.description ?? group.description}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card px-4",
|
||||
groupOff &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={pending[def.key]}
|
||||
error={errors[def.key]}
|
||||
disabled={fieldsDisabled}
|
||||
onChange={(v) =>
|
||||
handleChange(def.key, v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Always-mounted live region so save results are announced */}
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{saveMessage?.text ?? ""}
|
||||
</p>
|
||||
|
||||
{(dirtyCount > 0 || saveMessage) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||
saveMessage?.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: saveMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{saveMessage?.ok && (
|
||||
<Check
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{saveMessage && !saveMessage.ok
|
||||
? saveMessage.text
|
||||
: dirtyCount > 0
|
||||
? dict.admin.unsavedChanges
|
||||
: saveMessage?.text}
|
||||
</p>
|
||||
{dirtyCount > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
setEnabledGroups(
|
||||
groupsWithValues(settings),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{dict.admin.discard}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !writable}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.saving}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.saveChanges
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import { Eye, EyeOff, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isSecretValue,
|
||||
type SecretValue,
|
||||
type SettingState,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
|
||||
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||
|
||||
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||
const dict = useDictionary()
|
||||
if (source === "default") return null
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
source === "file"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={
|
||||
source === "file"
|
||||
? dict.admin.sourceSavedTitle
|
||||
: dict.admin.sourceEnvTitle
|
||||
}
|
||||
>
|
||||
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RestartBadge() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
{dict.admin.restartRequired}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||
export function SecretInput({
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
keepOnEmpty,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
value: string | SecretValue | undefined
|
||||
disabled?: boolean
|
||||
keepOnEmpty?: boolean
|
||||
onChange: (value: string | SecretValue) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||
// passes server state (a marker or nothing), e.g. after a save.
|
||||
const [original, setOriginal] = useState(value)
|
||||
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||
: dict.admin.notSet
|
||||
const handleText = (t: string) => {
|
||||
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||
onChange(original)
|
||||
} else {
|
||||
onChange(t)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type={show ? "text" : "password"}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
className="h-9 font-mono text-xs"
|
||||
onChange={(e) => handleText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={dict.admin.removeValue}
|
||||
title={dict.admin.removeValueTitle}
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── General settings field (registry-driven) ─────────────────────────
|
||||
|
||||
export function SettingField({
|
||||
def,
|
||||
state,
|
||||
pendingValue,
|
||||
error,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
state: SettingState | undefined
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
// undefined drops the pending change (back to the saved value)
|
||||
onChange: (value: string | null | undefined) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
const source = state?.source ?? "default"
|
||||
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||
|
||||
// Localized label/description keyed by env var name, falling back to the
|
||||
// registry's English (the registry stays canonical for the server).
|
||||
const t = (
|
||||
dict.admin.settings as Record<
|
||||
string,
|
||||
{ label?: string; description?: string } | undefined
|
||||
>
|
||||
)[def.key]
|
||||
const label = t?.label ?? def.label
|
||||
const description = t?.description ?? def.description
|
||||
|
||||
const inputId = `setting-${def.key}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
let control: React.ReactNode
|
||||
switch (def.type) {
|
||||
case "boolean": {
|
||||
// When unset, reflect the built-in runtime default so the toggle
|
||||
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||
const effective =
|
||||
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||
// A saved boolean can be cleared back to its env/default value.
|
||||
const canClear =
|
||||
(isDirty && pendingValue !== null) || source === "file"
|
||||
control = (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={effective === "true"}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
{canClear && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{dict.admin.resetToDefault}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder={dict.admin.notSet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
keepOnEmpty={source === "file"}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? undefined)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case "number":
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-xs tabular-nums"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-md"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<SourceChip source={source} />
|
||||
{def.restartRequired && <RestartBadge />}
|
||||
{isDirty && (
|
||||
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
{dict.admin.modified}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{control}
|
||||
<p
|
||||
id={errorId}
|
||||
className={cn(
|
||||
"text-xs text-destructive",
|
||||
error ? "mt-1.5" : "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-15
@@ -41,24 +41,19 @@ export async function generateMetadata({
|
||||
params: Promise<{ lang: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params
|
||||
const lang = (
|
||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
||||
) as Locale
|
||||
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
||||
|
||||
// Default to English metadata
|
||||
const titles: Record<Locale, string> = {
|
||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||
}
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||
"zh-Hant":
|
||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -85,14 +80,7 @@ export async function generateMetadata({
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale:
|
||||
lang === "zh"
|
||||
? "zh_CN"
|
||||
: lang === "zh-Hant"
|
||||
? "zh_HK"
|
||||
: lang === "ja"
|
||||
? "ja_JP"
|
||||
: "en_US",
|
||||
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
@@ -127,7 +115,6 @@ export async function generateMetadata({
|
||||
en: "/en",
|
||||
zh: "/zh",
|
||||
ja: "/ja",
|
||||
"zh-Hant": "/zh-Hant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+113
-94
@@ -1,25 +1,30 @@
|
||||
"use client"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
const drawioBaseUrl =
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
saveDiagramToStorage,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
@@ -27,16 +32,35 @@ export default function Home() {
|
||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [isElectron, setIsElectron] = useState(false)
|
||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||
)
|
||||
const [closeProtection, setCloseProtection] = useState(false)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const mouseOverDrawioRef = useRef(false)
|
||||
const isMobileRef = useRef(false)
|
||||
|
||||
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
||||
useEffect(() => {
|
||||
if (!showSaveDialog) {
|
||||
const timeout = setTimeout(() => {
|
||||
isSavingRef.current = false
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [showSaveDialog])
|
||||
|
||||
// Handle save from draw.io's built-in save button
|
||||
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
||||
// We use mouse position to determine if the user is interacting with draw.io
|
||||
const handleDrawioSave = useCallback(() => {
|
||||
if (!mouseOverDrawioRef.current) return
|
||||
if (isSavingRef.current) return
|
||||
isSavingRef.current = true
|
||||
setShowSaveDialog(true)
|
||||
}, [setShowSaveDialog])
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
@@ -47,15 +71,13 @@ export default function Home() {
|
||||
const currentLocale = pathParts[0]
|
||||
if (currentLocale !== savedLocale) {
|
||||
pathParts[0] = savedLocale
|
||||
// Keep the query (e.g. ?session=) and hash
|
||||
const { search, hash } = window.location
|
||||
router.replace(`/${pathParts.join("/")}${search}${hash}`)
|
||||
router.replace(`/${pathParts.join("/")}`)
|
||||
return // Wait for redirect
|
||||
}
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (isDrawioTheme(savedUi)) {
|
||||
if (savedUi === "min" || savedUi === "sketch") {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
@@ -72,52 +94,54 @@ export default function Home() {
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
|
||||
// Detect Electron and use bundled draw.io files for offline use
|
||||
// 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
|
||||
const electronDetected =
|
||||
!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`)
|
||||
const savedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
if (savedCloseProtection === "true") {
|
||||
setCloseProtection(true)
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
const handleDrawioLoad = useCallback(() => {
|
||||
setIsDrawioReady(true)
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDarkModeChange = () => {
|
||||
const handleDarkModeChange = async () => {
|
||||
await saveDiagramToStorage()
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||
document.documentElement.classList.toggle("dark", newValue)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||
localStorage.setItem("drawio-theme", theme)
|
||||
setDrawioUi(theme)
|
||||
setIsDrawioReady(false)
|
||||
const handleDrawioUiChange = async () => {
|
||||
await saveDiagramToStorage()
|
||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newUi)
|
||||
setDrawioUi(newUi)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile. The draw.io iframe is not remounted when crossing the
|
||||
// breakpoint (only the chat panel is), so its ready state stays as is.
|
||||
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
||||
const isInitialRenderRef = useRef(true)
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
const newIsMobile = window.innerWidth < 768
|
||||
if (
|
||||
!isInitialRenderRef.current &&
|
||||
newIsMobile !== isMobileRef.current
|
||||
) {
|
||||
saveDiagramToStorage().catch(() => {})
|
||||
resetDrawioReady()
|
||||
}
|
||||
isMobileRef.current = newIsMobile
|
||||
isInitialRenderRef.current = false
|
||||
setIsMobile(newIsMobile)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
}, [saveDiagramToStorage, resetDrawioReady])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
@@ -145,6 +169,20 @@ export default function Home() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
useEffect(() => {
|
||||
if (!closeProtection) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [closeProtection])
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
@@ -161,47 +199,35 @@ export default function Home() {
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
mouseOverDrawioRef.current = true
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
mouseOverDrawioRef.current = false
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
// draw.io names Traditional Chinese "zh-tw"
|
||||
lang:
|
||||
currentLang === "zh-Hant"
|
||||
? "zh-tw"
|
||||
: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
offline: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!isLoaded || !isDrawioReady) && (
|
||||
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||
<span className="text-muted-foreground">
|
||||
Draw.io panel is loading...
|
||||
</span>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||
{isLoaded ? (
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={onDrawioLoad}
|
||||
onSave={handleDrawioSave}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
lang: currentLang,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,23 +250,16 @@ export default function Home() {
|
||||
onExpand={() => setIsChatVisible(true)}
|
||||
>
|
||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
||||
Loading chat...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onDrawioUiChange={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Suspense>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
onCloseProtectionChange={setCloseProtection}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProvidersSchema,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import {
|
||||
getEnvFallback,
|
||||
isSettingsWritable,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function payload() {
|
||||
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
||||
// read-only in the panel; their credentials live in the environment
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const adminProviders = loadAdminProviders()
|
||||
// A panel default overrides any env default (matches the merge in
|
||||
// loadRawServerModelsConfig), so env stars must reflect that
|
||||
const adminHasDefault = adminProviders.some(
|
||||
(p) => p.isDefault && p.models.length > 0,
|
||||
)
|
||||
return {
|
||||
writable: isSettingsWritable(),
|
||||
providers: maskAdminProviders(adminProviders),
|
||||
envProviders:
|
||||
envConfig?.providers.map((p) => ({
|
||||
name: p.name,
|
||||
provider: p.provider,
|
||||
models: p.models,
|
||||
isDefault: !!p.default && !adminHasDefault,
|
||||
})) ?? [],
|
||||
// Whether .env sets a default model. getEnvFallback skips the value
|
||||
// the panel overlays onto process.env, so a panel default doesn't count.
|
||||
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
return Response.json(await payload())
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProvidersSchema.safeParse(
|
||||
(body as { providers?: unknown })?.providers,
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const stored = loadAdminProviders()
|
||||
const merged = mergeSecrets(parsed.data, stored)
|
||||
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const validationError = validateAdminProviders(merged, envConfig)
|
||||
if (validationError) {
|
||||
return Response.json({ error: validationError }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(deriveEnvUpdates(merged, stored))
|
||||
|
||||
return Response.json(await payload())
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
|
||||
import {
|
||||
getEnvFallback,
|
||||
getValueSource,
|
||||
isSettingsWritable,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import {
|
||||
SETTINGS_BY_KEY,
|
||||
SETTINGS_REGISTRY,
|
||||
type SettingDef,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function serializeSettings() {
|
||||
const fileValues = loadSettings()
|
||||
return SETTINGS_REGISTRY.map((def) => {
|
||||
const source = getValueSource(def.key)
|
||||
const raw =
|
||||
source === "file"
|
||||
? fileValues[def.key]
|
||||
: (getEnvFallback(def.key) ?? null)
|
||||
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
|
||||
return { key: def.key, source, value }
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
return Response.json({
|
||||
writable: isSettingsWritable(),
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
function validateValue(def: SettingDef, value: string): string | null {
|
||||
switch (def.type) {
|
||||
case "number": {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return "Must be a number"
|
||||
if (def.min !== undefined && num < def.min)
|
||||
return `Must be at least ${def.min}`
|
||||
if (def.max !== undefined && num > def.max)
|
||||
return `Must be at most ${def.max}`
|
||||
return null
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: 'Must be "true" or "false"'
|
||||
case "enum":
|
||||
return def.options?.includes(value)
|
||||
? null
|
||||
: `Must be one of: ${def.options?.join(", ")}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { values?: Record<string, unknown> }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
if (!body.values || typeof body.values !== "object") {
|
||||
return Response.json(
|
||||
{ error: "Body must contain a values object" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const updates: Record<string, string | null> = {}
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(body.values)) {
|
||||
const def = SETTINGS_BY_KEY.get(key)
|
||||
if (!def) {
|
||||
errors[key] = "Unknown setting"
|
||||
continue
|
||||
}
|
||||
if (value === null || value === "") {
|
||||
updates[key] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
errors[key] = "Value must be a string"
|
||||
continue
|
||||
}
|
||||
const error = validateValue(def, value)
|
||||
if (error) {
|
||||
errors[key] = error
|
||||
continue
|
||||
}
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return Response.json({ errors }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(updates)
|
||||
|
||||
return Response.json({
|
||||
writable: true,
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProviderSchema,
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// Test a model with the client's CURRENT provider state (which may be
|
||||
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
||||
// masked {isSet} markers, which are resolved against settings.json — so
|
||||
// testing works both before and after saving.
|
||||
export async function POST(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
let body: { provider?: unknown; modelId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProviderSchema.safeParse(body.provider)
|
||||
if (!parsed.success || !body.modelId) {
|
||||
return Response.json(
|
||||
{ valid: false, error: "Invalid provider or model" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
||||
// the endpoint it would be sent to (provider + baseUrl) still matches
|
||||
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
||||
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
||||
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
||||
const sameEndpoint =
|
||||
stored &&
|
||||
stored.provider === parsed.data.provider &&
|
||||
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
||||
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
||||
const [resolved] = mergeSecrets(
|
||||
[parsed.data],
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
awsRegion: resolved.awsRegion,
|
||||
vertexApiKey: resolved.vertexApiKey,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
+450
-372
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
||||
format: z.enum(["drawio", "png", "svg"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||
|
||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||
// would produce mojibake; the article-extractor library does the same detection
|
||||
// when it fetches the page itself, which we no longer rely on.
|
||||
function detectCharset(
|
||||
contentType: string | null,
|
||||
buffer: ArrayBuffer,
|
||||
): string {
|
||||
// 1. HTTP Content-Type header charset (most authoritative).
|
||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||
const metaCharset =
|
||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||
try {
|
||||
new TextDecoder(charset)
|
||||
return charset
|
||||
} catch {
|
||||
return "utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
|
||||
// huge download can't exhaust server memory. Returns null when too large.
|
||||
async function readLimitedBody(
|
||||
response: Response,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
|
||||
return null
|
||||
}
|
||||
if (!response.body) return new ArrayBuffer(0)
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel()
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return new Blob(chunks as BlobPart[]).arrayBuffer()
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SSRF protection: parse-url has no use case for fetching internal
|
||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||
if (await isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Fetch the page ourselves so we control redirect handling. The
|
||||
// article-extractor library follows redirects internally and ignores a
|
||||
// `redirect` option, which would let a public URL 302 to an internal
|
||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||
// redirect outright.
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
|
||||
let html: string
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (contentType?.includes("application/pdf")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
||||
},
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await readLimitedBody(response)
|
||||
if (!buffer) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
||||
},
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
{ error: "Timed out while fetching URL content" },
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||
// "unexpected redirect") when redirect: "error" is set.
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
// so map any parse error to the same 400 as the no-content case.
|
||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||
try {
|
||||
article = await extractFromHtml(html, url)
|
||||
} catch {
|
||||
article = null
|
||||
}
|
||||
|
||||
if (!article || !article.content) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
})
|
||||
|
||||
// Remove unwanted elements before conversion
|
||||
turndownService.remove(["script", "style", "iframe", "noscript"])
|
||||
|
||||
const markdown = turndownService.turndown(article.content)
|
||||
|
||||
// Check content length
|
||||
if (markdown.length > MAX_CONTENT_LENGTH) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
title: article.title || "Untitled",
|
||||
content: markdown,
|
||||
charCount: markdown.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("URL extraction error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch or parse URL content" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { canListModels, listProviderModels } from "@/lib/provider-models"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
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) {
|
||||
// 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)
|
||||
const { code, message } = classifyLLMError(error)
|
||||
return NextResponse.json({ code, error: message })
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
||||
|
||||
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
||||
// This ensures Docker users can set these values when starting containers
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const models = await loadFlattenedServerModels()
|
||||
return NextResponse.json({
|
||||
models,
|
||||
hasConfig: models.length > 0,
|
||||
})
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* API endpoint for VLM-based diagram validation.
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
*/
|
||||
|
||||
import { Output, streamText } from "ai"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
type ValidationResult,
|
||||
ValidationResultSchema,
|
||||
} from "@/lib/validation-schema"
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
// 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> {
|
||||
// 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 },
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+240
-104
@@ -1,16 +1,78 @@
|
||||
import { streamText, tool } from "ai"
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
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 { z } from "zod"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
|
||||
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 {
|
||||
provider: ProviderName
|
||||
provider: string
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
@@ -18,26 +80,9 @@ interface ValidateRequest {
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
// Vertex AI specific
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
}
|
||||
|
||||
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) {
|
||||
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
@@ -48,8 +93,6 @@ export async function POST(req: Request) {
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
|
||||
if (!provider || !modelId) {
|
||||
@@ -60,7 +103,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -78,103 +121,196 @@ export async function POST(req: Request) {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider === "vertexai") {
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// The Test button checks the user's own provider. On the server's
|
||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||
if (
|
||||
usesServerCredentials(provider, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
vertexApiKey,
|
||||
})
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
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") || "",
|
||||
},
|
||||
}),
|
||||
})
|
||||
let model: any
|
||||
|
||||
// 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
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
if (part.type === "finish") finishReason = part.finishReason
|
||||
|
||||
case "anthropic": {
|
||||
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.com/v1",
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
const ollama = createOllama({
|
||||
baseURL: baseUrl || "http://localhost:11434",
|
||||
})
|
||||
model = ollama(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "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 uses DeepSeek-compatible API
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL:
|
||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
||||
})
|
||||
model = doubao(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Make a minimal test request
|
||||
const startTime = Date.now()
|
||||
await generateText({
|
||||
model,
|
||||
prompt: "Say 'OK'",
|
||||
maxOutputTokens: 20,
|
||||
})
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
...(!calledTool &&
|
||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
const { code, message } = classifyLLMError(error)
|
||||
let errorMessage = "Validation failed"
|
||||
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(
|
||||
{ valid: false, code, error: message },
|
||||
{ valid: false, error: errorMessage },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (checkAccessCode(req)) {
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
// If no access codes configured, verification always passes
|
||||
if (accessCodes.length === 0) {
|
||||
return Response.json({
|
||||
valid: true,
|
||||
message: "No access code required",
|
||||
})
|
||||
}
|
||||
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
|
||||
if (!accessCodeHeader) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid or missing access code" },
|
||||
{ valid: false, message: "Access code is required" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid access code" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
+3
-16
@@ -74,8 +74,8 @@
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
/* Muted rose destructive */
|
||||
--destructive: oklch(0.45 0.12 10);
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
@@ -122,7 +122,7 @@
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
|
||||
--destructive: oklch(0.55 0.12 10);
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
@@ -244,19 +244,6 @@
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.35 0.015 260);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.45 0.015 260);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
|
||||
+2
-8
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => {
|
||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// Show shadow if there is more content below
|
||||
// Using a small threshold to handle fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkScroll()
|
||||
|
||||
// Event listeners
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
|
||||
// Observe content changes (e.g. async loading of items)
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}: ModelSelectorListProps) => (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
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="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
@@ -177,7 +134,6 @@ export const ModelSelectorLogo = ({
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
@@ -212,27 +168,3 @@ export const ModelSelectorName = ({
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSectionHeaderProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModelSelectorSectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: ModelSelectorSectionHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -70,11 +70,9 @@ function ExampleCard({
|
||||
export default function ExamplePanel({
|
||||
setInput,
|
||||
setFiles,
|
||||
minimal = false,
|
||||
}: {
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
|
||||
@@ -122,52 +120,49 @@ export default function ExamplePanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||
{!minimal && (
|
||||
<>
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
||||
{dict.examples.preview}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.examples.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.examples.subtitle}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.examples.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.examples.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Examples grid */}
|
||||
<div className="space-y-3">
|
||||
{!minimal && (
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
{dict.examples.quickExamples}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
{dict.examples.quickExamples}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<ExampleCard
|
||||
|
||||
+241
-438
@@ -1,62 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Link,
|
||||
Loader2,
|
||||
Send,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
type Dispatch,
|
||||
forwardRef,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||
import { isRealDiagram } from "@/lib/utils"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
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 {
|
||||
return (
|
||||
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||
isPdfFile(file) ||
|
||||
isTextFile(file)
|
||||
)
|
||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -160,24 +135,18 @@ function showValidationErrors(errors: string[], dict: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onStop?: () => void
|
||||
onClearChat: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
pdfData?: Map<
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<string, UrlData>
|
||||
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
@@ -185,332 +154,226 @@ interface ChatInputProps {
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
onConfigureModels?: () => void
|
||||
showUnvalidatedModels?: boolean
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
onFocused?: () => void
|
||||
onConfigureModels?: () => void
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput(
|
||||
{
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onStop,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onUrlChange,
|
||||
sessionId,
|
||||
error = null,
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
onConfigureModels,
|
||||
showUnvalidatedModels = false,
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
chartXML,
|
||||
diagramHistory,
|
||||
saveDiagramToFile,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
export function ChatInput({
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onClearChat,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
sessionId,
|
||||
error = null,
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
showUnvalidatedModels = false,
|
||||
onConfigureModels = () => {},
|
||||
}: ChatInputProps) {
|
||||
const dict = useDictionary()
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
|
||||
// Expose focus method via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
}))
|
||||
|
||||
// Focus the textarea when shouldFocus becomes true
|
||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||
useEffect(() => {
|
||||
if (shouldFocus) {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
onFocused?.()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [shouldFocus, onFocused])
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
// Block sending until attached files and URLs have their text, otherwise
|
||||
// their content would be silently dropped
|
||||
const isExtractingAttachments =
|
||||
files.some((file) => pdfData.get(file)?.isExtracting) ||
|
||||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}, [])
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
// Load send shortcut preference from localStorage and listen for changes
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||
if (stored) setSendShortcut(stored)
|
||||
|
||||
const handleChange = (e: CustomEvent<string>) =>
|
||||
setSendShortcut(e.detail)
|
||||
window.addEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}, [])
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Enter that confirms an IME candidate must not send the message
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey
|
||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (
|
||||
form &&
|
||||
input.trim() &&
|
||||
!isDisabled &&
|
||||
!isExtractingAttachments
|
||||
) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
|
||||
const items = e.clipboardData.items
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(
|
||||
newFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
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,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlExtract = async (url: string) => {
|
||||
if (!onUrlChange) return
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
|
||||
setIsExtractingUrl(true)
|
||||
const items = e.clipboardData.items
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
// Use functional updates so a removal or send made while extracting
|
||||
// is not overwritten when the request finishes
|
||||
try {
|
||||
onUrlChange((prev) =>
|
||||
new Map(prev).set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
|
||||
const 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)
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="chat-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) =>
|
||||
onUrlChange((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(
|
||||
newFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (isDisabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||
isValidFileType(file),
|
||||
)
|
||||
|
||||
const { validFiles, errors } = validateFiles(
|
||||
supportedFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
onClearChat()
|
||||
setShowClearDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full transition-all duration-200 ${
|
||||
isDragging
|
||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
pdfData={pdfData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={dict.chat.placeholder}
|
||||
disabled={isDisabled}
|
||||
aria-label="Chat input"
|
||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
tooltipContent={dict.chat.clearConversation}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ResetWarningModal
|
||||
open={showClearDialog}
|
||||
onOpenChange={setShowClearDialog}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div 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 gap-1 overflow-hidden justify-end">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
@@ -531,9 +394,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
disabled={
|
||||
isDisabled || !isRealDiagram(chartXML)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.saveDiagram}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -552,38 +413,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
<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"
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -597,73 +432,41 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
{(status === "streaming" || status === "submitted") &&
|
||||
onStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
||||
aria-label={dict.chat.stopGeneration}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<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" />
|
||||
{dict.chat.send}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
{dict.chat.send}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
/>
|
||||
<SaveDialog
|
||||
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>
|
||||
)
|
||||
},
|
||||
)
|
||||
</div>
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
/>
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
+537
-411
File diff suppressed because it is too large
Load Diff
+372
-805
File diff suppressed because it is too large
Load Diff
@@ -1,360 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { Template } from "@/lib/template-storage"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
thumbnailDataUrl?: string
|
||||
}
|
||||
|
||||
interface ChatLobbyProps {
|
||||
sessions: SessionMetadata[]
|
||||
onSelectSession: (id: string) => void
|
||||
onDeleteSession?: (id: string) => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
dict: {
|
||||
sessionHistory?: {
|
||||
recentChats?: string
|
||||
searchPlaceholder?: string
|
||||
noResults?: string
|
||||
justNow?: string
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
templates?: {
|
||||
title?: string
|
||||
myTemplates?: string
|
||||
}
|
||||
examples?: {
|
||||
quickExamples?: string
|
||||
}
|
||||
common: {
|
||||
delete: string
|
||||
cancel: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to format session date
|
||||
function formatSessionDate(
|
||||
timestamp: number,
|
||||
dict?: { justNow?: string },
|
||||
): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
|
||||
if (diffMins < 1) return dict?.justNow || "Just now"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function getPanelVisibility() {
|
||||
if (typeof window === "undefined")
|
||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
||||
return {
|
||||
recentChats:
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
myTemplates:
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
quickExamples:
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatLobby({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
setInput,
|
||||
setFiles,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
dict,
|
||||
}: ChatLobbyProps) {
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Listen for panel visibility changes from settings
|
||||
useEffect(() => {
|
||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
||||
window.addEventListener("panelVisibilityChange", handler)
|
||||
return () =>
|
||||
window.removeEventListener("panelVisibilityChange", handler)
|
||||
}, [])
|
||||
|
||||
const hasHistory = sessions.length > 0
|
||||
|
||||
if (!hasHistory) {
|
||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{panelVisibility.myTemplates && (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show history + collapsible examples when there are sessions
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Recent Chats Section */}
|
||||
{panelVisibility.recentChats && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the delete button
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible My Templates Section */}
|
||||
{panelVisibility.myTemplates && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.templates?.myTemplates || "My Templates"}
|
||||
</span>
|
||||
{templatesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{templatesExpanded && (
|
||||
<div className="mt-2">
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Quick Examples Section */}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.sessionHistory?.deleteTitle ||
|
||||
"Delete this chat?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.sessionHistory?.deleteDescription ||
|
||||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (sessionToDelete && onDeleteSession) {
|
||||
onDeleteSession(sessionToDelete)
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setSessionToDelete(null)
|
||||
}}
|
||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Plus } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
createTemplate,
|
||||
type TemplateCreateInput,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialPrompt = "",
|
||||
}: TemplateCreateDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with the latest initialPrompt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPrompt(initialPrompt)
|
||||
setPinned(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: TemplateCreateInput = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const template = await createTemplate(input)
|
||||
if (template) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.createFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create template:", err)
|
||||
setError(dict.templates.createFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
{dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt" className="text-foreground">
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-foreground">
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Edit2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
template: Template | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateEditDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
useEffect(() => {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || template.title,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const updated = await updateTemplate(template.id, updates)
|
||||
if (updated) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.updateFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err)
|
||||
setError(dict.templates.updateFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
{dict.templates.editTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.editDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-prompt"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-title"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="edit-pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,647 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Bookmark,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
exportTemplates,
|
||||
getAllTemplates,
|
||||
importTemplates,
|
||||
incrementClickCount,
|
||||
incrementRunCount,
|
||||
searchTemplates,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
if (!timestamp) return neverUsedText
|
||||
const now = Date.now()
|
||||
const diffMs = now - timestamp
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||
if (diffMins < 1) return rtf.format(0, "minute")
|
||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||
} catch {
|
||||
// Fallback if Intl.RelativeTimeFormat is not available
|
||||
if (diffMins < 1) return "<1m ago"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||
null,
|
||||
)
|
||||
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [importMessage, setImportMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
const result = await getAllTemplates()
|
||||
setTemplates(result)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Filter templates by search query
|
||||
const filteredTemplates = searchQuery.trim()
|
||||
? searchTemplates(templates, searchQuery)
|
||||
: templates
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
expandedTools: Record<string, boolean>
|
||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||
copiedToolCallId: string | null
|
||||
copyFailedToolCallId: string | null
|
||||
dict: {
|
||||
tools: { complete: string }
|
||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||
}
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
part,
|
||||
expandedTools,
|
||||
setExpandedTools,
|
||||
onCopy,
|
||||
copiedToolCallId,
|
||||
copyFailedToolCallId,
|
||||
dict,
|
||||
}: ToolCallCardProps) {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output, errorText } = part
|
||||
// Default to expanded for all states (user can manually collapse if needed)
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const isCopied = copiedToolCallId === callId
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[callId]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Incomplete XML means the output hit the length limit, unless the user
|
||||
// stopped the generation themselves
|
||||
const isTruncated =
|
||||
state === "output-error" &&
|
||||
errorText !== "Stopped by user" &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
if (input.xml) {
|
||||
textToCopy = input.xml
|
||||
} else if (input.operations && Array.isArray(input.operations)) {
|
||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
||||
} else if (Object.keys(input).length > 0) {
|
||||
textToCopy = JSON.stringify(input, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{getToolDisplayName(toolName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "input-streaming" && (
|
||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{state === "output-available" && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
{dict.tools.complete}
|
||||
</span>
|
||||
{isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={
|
||||
copiedToolCallId === callId
|
||||
? dict.chat.copied
|
||||
: copyFailedToolCallId === callId
|
||||
? dict.chat.failedToCopy
|
||||
: dict.chat.copyResponse
|
||||
}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
))}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{input && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available" ? (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{input.xml}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{/* AI SDK stores tool errors in errorText */}
|
||||
{state === "output-error" && (errorText || output) && (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
||||
: (errorText ?? output)}
|
||||
</div>
|
||||
)}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
export type ValidationStatus =
|
||||
| "idle"
|
||||
| "capturing"
|
||||
| "validating"
|
||||
| "success"
|
||||
| "success_with_warnings"
|
||||
| "failed"
|
||||
| "error"
|
||||
| "skipped"
|
||||
|
||||
export interface ValidationState {
|
||||
status: ValidationStatus
|
||||
attempt?: number
|
||||
maxAttempts?: number
|
||||
result?: ValidationResult
|
||||
error?: string
|
||||
imageData?: string // Base64 PNG data URL
|
||||
}
|
||||
|
||||
interface ValidationCardProps {
|
||||
state: ValidationState
|
||||
onImproveWithSuggestions?: (feedback: string) => void
|
||||
}
|
||||
|
||||
export function ValidationCard({
|
||||
state,
|
||||
onImproveWithSuggestions,
|
||||
}: ValidationCardProps) {
|
||||
const dict = useDictionary()
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
state.status === "validating" || state.status === "failed",
|
||||
)
|
||||
const [hasRequestedImprovement, setHasRequestedImprovement] =
|
||||
useState(false)
|
||||
|
||||
// Generate improvement feedback from validation result
|
||||
const generateImprovementFeedback = (): string => {
|
||||
if (!state.result) return ""
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
"Please improve the diagram based on the following visual analysis feedback:",
|
||||
)
|
||||
lines.push("")
|
||||
|
||||
if (state.result.issues.length > 0) {
|
||||
lines.push("Issues to address:")
|
||||
for (const issue of state.result.issues) {
|
||||
lines.push(
|
||||
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (state.result.suggestions.length > 0) {
|
||||
lines.push("Suggestions for improvement:")
|
||||
for (const suggestion of state.result.suggestions) {
|
||||
lines.push(` - ${suggestion}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
lines.push("Regenerate the diagram with these improvements applied.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const handleImproveClick = () => {
|
||||
if (
|
||||
!onImproveWithSuggestions ||
|
||||
!state.result ||
|
||||
hasRequestedImprovement
|
||||
)
|
||||
return
|
||||
setHasRequestedImprovement(true)
|
||||
const feedback = generateImprovementFeedback()
|
||||
onImproveWithSuggestions(feedback)
|
||||
}
|
||||
|
||||
// Check if we should show the improve button
|
||||
const showImproveButton =
|
||||
onImproveWithSuggestions &&
|
||||
state.result &&
|
||||
(state.status === "success" ||
|
||||
state.status === "success_with_warnings" ||
|
||||
state.status === "skipped") &&
|
||||
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
switch (state.status) {
|
||||
case "capturing":
|
||||
return {
|
||||
label: dict.validation.capturing,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "validating":
|
||||
return {
|
||||
label: state.attempt
|
||||
? dict.validation.validatingWithAttempt
|
||||
.replace("{attempt}", String(state.attempt))
|
||||
.replace("{max}", String(state.maxAttempts || 3))
|
||||
: dict.validation.validating,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "success":
|
||||
return {
|
||||
label: dict.validation.valid,
|
||||
color: "text-green-600 bg-green-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "success_with_warnings":
|
||||
return {
|
||||
label: dict.validation.validWithWarnings,
|
||||
color: "text-amber-600 bg-amber-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "failed":
|
||||
return {
|
||||
label: dict.validation.issuesFound,
|
||||
color: "text-yellow-600 bg-yellow-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
label: dict.validation.error,
|
||||
color: "text-red-600 bg-red-50",
|
||||
icon: <X className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "skipped":
|
||||
return {
|
||||
label: dict.validation.skipped,
|
||||
color: "text-gray-600 bg-gray-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const statusDisplay = getStatusDisplay()
|
||||
if (!statusDisplay || state.status === "idle") return null
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Eye
|
||||
className="w-3.5 h-3.5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{dict.validation.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
<span className="ml-1">{statusDisplay.label}</span>
|
||||
</span>
|
||||
{(state.result || state.error) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation details when expanded */}
|
||||
{isExpanded && (state.result || state.imageData) && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
|
||||
{/* Captured image */}
|
||||
{state.imageData && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
|
||||
<ImageIcon
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.capturedScreenshot}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
|
||||
<Image
|
||||
src={state.imageData}
|
||||
alt="Captured diagram for validation"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-auto max-h-48 object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{state.result && state.result.issues.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.issuesFoundLabel}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.result.issues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-xs px-3 py-2 rounded-lg border ${
|
||||
issue.severity === "critical"
|
||||
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
|
||||
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium uppercase text-[10px] mr-2">
|
||||
[{issue.type}]
|
||||
</span>
|
||||
{issue.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{state.result && state.result.suggestions.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.suggestions}
|
||||
</div>
|
||||
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
|
||||
{state.result.suggestions.map(
|
||||
(suggestion, index) => (
|
||||
<li key={index}>{suggestion}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valid result message */}
|
||||
{state.result?.valid &&
|
||||
state.result.issues.length === 0 && (
|
||||
<div className="text-xs text-green-600 dark:text-green-400">
|
||||
{dict.validation.passedValidation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
|
||||
{showImproveButton && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
|
||||
{hasRequestedImprovement ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
{dict.validation.improvementRequested}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImproveClick}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.improveWithSuggestions}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
{dict.validation.regenerateWithFeedback}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details when expanded */}
|
||||
{isExpanded && state.error && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
|
||||
<div className="text-xs text-red-600">{state.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
|
||||
export type { DiagramOperation }
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const prepared = prepareNewDiagram(xml)
|
||||
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||
import { FileCode, FileText, Loader2, X } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
|
||||
@@ -20,19 +20,12 @@ interface FilePreviewListProps {
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<
|
||||
string,
|
||||
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
onRemoveUrl?: (url: string) => void
|
||||
}
|
||||
|
||||
export function FilePreviewList({
|
||||
files,
|
||||
onRemoveFile,
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onRemoveUrl,
|
||||
}: FilePreviewListProps) {
|
||||
const dict = useDictionary()
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
@@ -84,7 +77,7 @@ export function FilePreviewList({
|
||||
}
|
||||
}, [imageUrls, selectedImage])
|
||||
|
||||
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -159,59 +152,6 @@ export function FilePreviewList({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* URL previews */}
|
||||
{urlData && urlData.size > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(urlData.entries()).map(
|
||||
([url, data], index) => (
|
||||
<div
|
||||
key={url + index}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
|
||||
<div className="flex flex-col items-center justify-center h-full p-1">
|
||||
{data.isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dict.file.reading}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="h-6 w-6 text-blue-500 mb-1" />
|
||||
<span className="text-xs text-center truncate w-full px-1">
|
||||
{data.title.length > 10
|
||||
? `${data.title.slice(0, 7)}...`
|
||||
: data.title}
|
||||
</span>
|
||||
{data.charCount && (
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
{formatCharCount(
|
||||
data.charCount,
|
||||
)}{" "}
|
||||
{dict.file.chars}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onRemoveUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUrl(url)}
|
||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={dict.file.removeFile}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Image Modal/Lightbox */}
|
||||
{selectedImage && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -43,7 +43,7 @@ export function HistoryDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import NextImage, { type ImageProps } from "next/image"
|
||||
import { forwardRef } from "react"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
export default forwardRef<HTMLImageElement, ImageProps>(
|
||||
function Image(props, ref) {
|
||||
const src =
|
||||
typeof props.src === "string" &&
|
||||
props.src.startsWith("/") &&
|
||||
!props.src.startsWith("//")
|
||||
? getAssetUrl(props.src)
|
||||
: props.src
|
||||
|
||||
return <NextImage {...props} src={src} ref={ref} />
|
||||
},
|
||||
)
|
||||
+1046
-943
File diff suppressed because it is too large
Load Diff
+119
-261
@@ -5,10 +5,8 @@ import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
@@ -21,27 +19,39 @@ import {
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSectionHeader,
|
||||
ModelSelectorSeparator,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_LOGO_MAP,
|
||||
} from "@/lib/types/model-config"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure?: () => void
|
||||
onConfigure: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Map our provider names to models.dev logo names
|
||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
google: "google",
|
||||
azure: "azure",
|
||||
bedrock: "amazon-bedrock",
|
||||
openrouter: "openrouter",
|
||||
deepseek: "deepseek",
|
||||
siliconflow: "siliconflow",
|
||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
||||
gateway: "vercel",
|
||||
edgeone: "tencent-cloud",
|
||||
doubao: "bytedance",
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
@@ -51,11 +61,7 @@ function groupModelsByProvider(
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
for (const model of models) {
|
||||
// For server models, strip "Server · " prefix for cleaner grouping
|
||||
const key =
|
||||
model.source === "server"
|
||||
? model.providerLabel.replace(/^Server · /, "")
|
||||
: model.providerLabel
|
||||
const key = model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
@@ -83,26 +89,10 @@ export function ModelSelector({
|
||||
}
|
||||
return models.filter((m) => m.validated === true)
|
||||
}, [models, showUnvalidatedModels])
|
||||
|
||||
// Separate server and user models
|
||||
const serverModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source === "server"),
|
||||
const groupedModels = useMemo(
|
||||
() => groupModelsByProvider(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
|
||||
const selectedModel = useMemo(
|
||||
@@ -111,7 +101,9 @@ export function ModelSelector({
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__server_default__") {
|
||||
if (value === "__configure__") {
|
||||
onConfigure()
|
||||
} else if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
} else {
|
||||
onSelect(value)
|
||||
@@ -158,7 +150,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<div ref={wrapperRef} className="inline-block">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -167,7 +159,7 @@ export function ModelSelector({
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
@@ -176,7 +168,7 @@ export function ModelSelector({
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
<span className="text-xs truncate">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
@@ -197,247 +189,113 @@ export function ModelSelector({
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 && models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
{/* Server Default Option */}
|
||||
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
|
||||
{/* Configured Models by Provider */}
|
||||
{Array.from(groupedModels.entries()).map(
|
||||
([
|
||||
providerLabel,
|
||||
{ provider, models: providerModels },
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={providerLabel}
|
||||
heading={providerLabel}
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
key={model.id}
|
||||
value={model.modelId}
|
||||
onSelect={() =>
|
||||
handleSelect(model.id)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
selectedModelId === model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] || provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
{model.modelId}
|
||||
</ModelSelectorName>
|
||||
{model.validated !== true && (
|
||||
<span
|
||||
title={
|
||||
dict.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
)}
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig.serverModels
|
||||
}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedServerModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedUserModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
{/* Configure Option */}
|
||||
<ModelSelectorSeparator />
|
||||
<ModelSelectorGroup>
|
||||
<ModelSelectorItem
|
||||
value="__configure__"
|
||||
onSelect={handleSelect}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
{/* Info text */}
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</div>
|
||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||
{onConfigure && (
|
||||
<div className="px-3 py-2">
|
||||
<ModelSelectorItem
|
||||
value="__configure_models__"
|
||||
onSelect={() => {
|
||||
onConfigure()
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
</div>
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Key, Link2, Tag } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
// renderSecret. That (and the optional test action) are the only genuine
|
||||
// differences between the two screens; the field structure is shared here.
|
||||
export type SecretField =
|
||||
| "apiKey"
|
||||
| "awsAccessKeyId"
|
||||
| "awsSecretAccessKey"
|
||||
| "vertexApiKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
["us-east-2", "Ohio"],
|
||||
["us-west-2", "Oregon"],
|
||||
["eu-west-1", "Ireland"],
|
||||
["eu-west-2", "London"],
|
||||
["eu-west-3", "Paris"],
|
||||
["eu-central-1", "Frankfurt"],
|
||||
["ap-south-1", "Mumbai"],
|
||||
["ap-northeast-1", "Tokyo"],
|
||||
["ap-northeast-2", "Seoul"],
|
||||
["ap-southeast-1", "Singapore"],
|
||||
["ap-southeast-2", "Sydney"],
|
||||
["sa-east-1", "São Paulo"],
|
||||
]
|
||||
|
||||
interface ProviderCredentialsFieldsProps {
|
||||
provider: ProviderName
|
||||
// Plain (non-secret) field values — secrets are owned by renderSecret
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
// Render the control for a secret field. The caller may include trailing
|
||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||
// shared component only supplies the label above it.
|
||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
// Display name + per-provider credential inputs, shared by the user
|
||||
// ModelConfigDialog and the admin Models panel.
|
||||
export function ProviderCredentialsFields({
|
||||
provider,
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-name"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.displayName}
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
value={name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("name", e.target.value)}
|
||||
placeholder={info.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-access-key-id"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsAccessKeyId}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsAccessKeyId",
|
||||
id: "aws-access-key-id",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-secret-access-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsSecretAccessKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsSecretAccessKey",
|
||||
id: "aws-secret-access-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Region */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-region"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsRegion}
|
||||
</Label>
|
||||
<Select
|
||||
value={awsRegion || ""}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange("awsRegion", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="aws-region"
|
||||
className="h-9 font-mono text-xs hover:bg-accent"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={dict.modelConfig.selectRegion}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{AWS_REGIONS.map(([region, label]) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region} ({label})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
<>
|
||||
{/* Vertex AI API Key (Express Mode) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "vertexApiKey",
|
||||
id: "vertex-api-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Base URL (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="vertex-base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={dict.modelConfig.customEndpoint}
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key, with a link to where the provider issues keys */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
// Drop a pasted endpoint path such as /chat/completions
|
||||
onBlur={(e) => {
|
||||
const normalized = normalizeBaseUrl(
|
||||
e.target.value,
|
||||
)
|
||||
if (normalized !== e.target.value) {
|
||||
onChange("baseUrl", normalized)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{requestUrl && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||
// that have no logo there
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: ProviderName
|
||||
className?: string
|
||||
}) {
|
||||
if (provider === "bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "sglang") {
|
||||
return <Server className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "doubao") {
|
||||
return <Sparkles className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${logoName}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -23,22 +23,9 @@ export function QuotaLimitToast({
|
||||
}: QuotaLimitToastProps) {
|
||||
const dict = useDictionary()
|
||||
const isTokenLimit = type === "token"
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
const formatNumber = (n: number) =>
|
||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||
|
||||
const quotaMessage = isTokenLimit
|
||||
? isSelfHosted
|
||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||
: dict.quota.messageToken
|
||||
: isSelfHosted
|
||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||
: dict.quota.messageApi
|
||||
|
||||
const tipHtml = isSelfHosted
|
||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
||||
: dict.quota.tip
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
@@ -84,24 +71,19 @@ export function QuotaLimitToast({
|
||||
</div>
|
||||
{/* Message */}
|
||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||
<p>{quotaMessage}</p>
|
||||
{!isSelfHosted && (
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatMessage(
|
||||
dict.quota.doubaoSponsorship,
|
||||
{
|
||||
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
||||
},
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
{isTokenLimit
|
||||
? dict.quota.messageToken
|
||||
: dict.quota.messageApi}
|
||||
</p>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: tipHtml,
|
||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
||||
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>
|
||||
</div>{" "}
|
||||
{/* Action buttons */}
|
||||
@@ -119,28 +101,24 @@ export function QuotaLimitToast({
|
||||
{dict.quota.configModel}
|
||||
</button>
|
||||
)}
|
||||
{!isSelfHosted && (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
{dict.quota.sponsor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
{dict.quota.sponsor}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
@@ -74,11 +74,6 @@ export function SaveDialog({
|
||||
label: dict.save.formats.svg,
|
||||
extension: ".svg",
|
||||
},
|
||||
{
|
||||
value: "xmlsvg" as const,
|
||||
label: dict.save.formats.xmlsvg,
|
||||
extension: ".drawio.svg",
|
||||
},
|
||||
]
|
||||
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||
|
||||
+54
-381
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,12 +21,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
@@ -58,28 +54,22 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
"zh-Hant": "繁體中文",
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
vlmValidationEnabled?: boolean
|
||||
onVlmValidationChange?: (value: boolean) => void
|
||||
onOpenModelConfig?: () => void
|
||||
customSystemMessage?: string
|
||||
onCustomSystemMessageChange?: (value: string) => void
|
||||
maxOutputTokens?: string
|
||||
onMaxOutputTokensChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
@@ -92,58 +82,30 @@ function getStoredAccessCodeRequired(): boolean | null {
|
||||
function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseProtectionChange,
|
||||
drawioUi,
|
||||
onDrawioUiChange,
|
||||
onToggleDrawioUi,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
vlmValidationEnabled = false,
|
||||
onVlmValidationChange = () => {},
|
||||
onOpenModelConfig,
|
||||
customSystemMessage = "",
|
||||
onCustomSystemMessageChange = () => {},
|
||||
maxOutputTokens = "",
|
||||
onMaxOutputTokensChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [closeProtection, setCloseProtection] = useState(true)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
|
||||
// Panel visibility state
|
||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
||||
|
||||
const handlePanelToggle = useCallback(
|
||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
||||
setter(value)
|
||||
localStorage.setItem(key, String(value))
|
||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Proxy settings state (Electron only)
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
const [httpsProxy, setHttpsProxy] = useState("")
|
||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||
// 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
|
||||
// Only fetch if not cached in localStorage
|
||||
if (getStoredAccessCodeRequired() !== null) return
|
||||
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
@@ -159,9 +121,10 @@ function SettingsContent({
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep existing cached value on error
|
||||
// Don't cache on error - allow retry on next mount
|
||||
setAccessCodeRequired(false)
|
||||
})
|
||||
}, [open])
|
||||
}, [])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
@@ -180,31 +143,13 @@ function SettingsContent({
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const storedSendShortcut = localStorage.getItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
)
|
||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||
|
||||
setShowRecentChats(
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
)
|
||||
setShowMyTemplates(
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
)
|
||||
setShowQuickExamples(
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
||||
"false",
|
||||
const storedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
|
||||
setError("")
|
||||
|
||||
// Load proxy settings (Electron only)
|
||||
if (window.electronAPI?.getProxy) {
|
||||
window.electronAPI.getProxy().then((config) => {
|
||||
setHttpProxy(config.httpProxy || "")
|
||||
setHttpsProxy(config.httpsProxy || "")
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -212,13 +157,6 @@ function SettingsContent({
|
||||
// Save locale to localStorage for persistence across restarts
|
||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||
|
||||
// Notify Electron main process to update its menu language
|
||||
if (window.electronAPI?.setUserLocale) {
|
||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
@@ -270,48 +208,8 @@ function SettingsContent({
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyProxy = async () => {
|
||||
if (!window.electronAPI?.setProxy) return
|
||||
|
||||
// Validate proxy URLs (must start with http:// or https://)
|
||||
const validateProxyUrl = (url: string): boolean => {
|
||||
if (!url) return true // Empty is OK
|
||||
return url.startsWith("http://") || url.startsWith("https://")
|
||||
}
|
||||
|
||||
const trimmedHttp = httpProxy.trim()
|
||||
const trimmedHttps = httpsProxy.trim()
|
||||
|
||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
||||
toast.error("HTTP Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingProxy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.setProxy({
|
||||
httpProxy: trimmedHttp || undefined,
|
||||
httpsProxy: trimmedHttps || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success(dict.settings.proxyApplied)
|
||||
} else {
|
||||
toast.error(result.error || "Failed to apply proxy settings")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to apply proxy settings")
|
||||
} finally {
|
||||
setIsApplyingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
@@ -321,29 +219,8 @@ function SettingsContent({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||
<div className="px-6 pb-6">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* 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) */}
|
||||
{accessCodeRequired && (
|
||||
<div className="py-4 first:pt-0 space-y-3">
|
||||
@@ -437,40 +314,42 @@ function SettingsContent({
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={dict.settings.drawioStyleDescription}
|
||||
description={`${dict.settings.drawioStyleDescription} ${
|
||||
drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch
|
||||
}`}
|
||||
>
|
||||
<Select
|
||||
value={drawioUi}
|
||||
onValueChange={(v) =>
|
||||
onDrawioUiChange(v as DrawioTheme)
|
||||
}
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
onClick={onToggleDrawioUi}
|
||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="drawio-ui-select"
|
||||
aria-label={dict.settings.drawioStyle}
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kennedy">
|
||||
{dict.settings.themeDefault}
|
||||
</SelectItem>
|
||||
<SelectItem value="atlas">Atlas</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{dict.settings.themeDark}
|
||||
</SelectItem>
|
||||
<SelectItem value="min">
|
||||
{dict.settings.themeMinimal}
|
||||
</SelectItem>
|
||||
<SelectItem value="sketch">
|
||||
{dict.settings.themeSketch}
|
||||
</SelectItem>
|
||||
<SelectItem value="simple">
|
||||
{dict.settings.themeSimple}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Close Protection */}
|
||||
<SettingItem
|
||||
label={dict.settings.closeProtection}
|
||||
description={dict.settings.closeProtectionDescription}
|
||||
>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={(checked) => {
|
||||
setCloseProtection(checked)
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
checked.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
@@ -491,212 +370,6 @@ function SettingsContent({
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Panel Visibility */}
|
||||
<SettingItem
|
||||
label={dict.settings.panelVisibility}
|
||||
description={dict.settings.panelVisibilityDescription}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-recent-chats"
|
||||
checked={showRecentChats}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showRecentChats,
|
||||
v,
|
||||
setShowRecentChats,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showRecentChats}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-my-templates"
|
||||
checked={showMyTemplates}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showMyTemplates,
|
||||
v,
|
||||
setShowMyTemplates,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showMyTemplates}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-quick-examples"
|
||||
checked={showQuickExamples}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showQuickExamples,
|
||||
v,
|
||||
setShowQuickExamples,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showQuickExamples}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* VLM Diagram Validation */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramValidation}
|
||||
description={dict.settings.diagramValidationDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="vlm-validation"
|
||||
checked={vlmValidationEnabled}
|
||||
onCheckedChange={onVlmValidationChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vlmValidationEnabled
|
||||
? dict.settings.enabled
|
||||
: dict.settings.disabled}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Custom System Message */}
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="custom-system-message"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.customSystemMessage}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.customSystemMessageDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={customSystemMessage}
|
||||
onChange={(e) =>
|
||||
onCustomSystemMessageChange(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
dict.settings.customSystemMessagePlaceholder
|
||||
}
|
||||
className="min-h-[80px] max-h-[160px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<SettingItem
|
||||
label={dict.settings.maxOutputTokens}
|
||||
description={dict.settings.maxOutputTokensDescription}
|
||||
>
|
||||
<Input
|
||||
id="max-output-tokens"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(e) =>
|
||||
onMaxOutputTokensChange(e.target.value)
|
||||
}
|
||||
placeholder="64000"
|
||||
className="h-9 w-28 text-sm"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
description={dict.settings.sendShortcutDescription}
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) => {
|
||||
setSendShortcut(value)
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
value,
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sendShortcutChange", {
|
||||
detail: value,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="w-auto h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{dict.settings.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{dict.settings.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Proxy Settings - Electron only */}
|
||||
{typeof window !== "undefined" &&
|
||||
window.electronAPI?.isElectron && (
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{dict.settings.proxy}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.proxyDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
type="text"
|
||||
value={httpProxy}
|
||||
onChange={(e) =>
|
||||
setHttpProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
type="text"
|
||||
value={httpsProxy}
|
||||
onChange={(e) =>
|
||||
setHttpsProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleApplyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 px-4 rounded-xl w-full"
|
||||
>
|
||||
{isApplyingProxy
|
||||
? "..."
|
||||
: dict.settings.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -77,13 +77,12 @@ function CommandInput({
|
||||
)
|
||||
}
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => {
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Link, Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
interface UrlInputDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (url: string) => void
|
||||
isExtracting: boolean
|
||||
}
|
||||
|
||||
export function UrlInputDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isExtracting,
|
||||
}: UrlInputDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [url, setUrl] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError("")
|
||||
|
||||
if (!url.trim()) {
|
||||
setError(dict.url.enterUrl)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
setError(dict.url.invalidFormat)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(url.trim())
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !isExtracting) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.url.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.url.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value)
|
||||
setError("")
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="https://example.com/article"
|
||||
disabled={isExtracting}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isExtracting}
|
||||
>
|
||||
{dict.url.Cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isExtracting || !url.trim()}
|
||||
>
|
||||
{isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{dict.url.Extracting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
{dict.url.extract}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+106
-156
@@ -1,39 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
BLANK_MXFILE,
|
||||
normalizeToMxfile,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
resolverRef: React.Ref<((value: string) => void) | null>
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => void
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
saveDiagramToStorage: () => Promise<void>
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
@@ -43,12 +34,6 @@ interface DiagramContextType {
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
// Exports for thumbnails, validation PNGs and file saves carry 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; untagged exports (chat-panel's
|
||||
// onFetchChart) resolve resolverRef.
|
||||
type ExportTag = "thumbnail" | "validation"
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
@@ -56,45 +41,75 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
{ svg: string; xml: string }[]
|
||||
>([])
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Pending thumbnail and validation PNG exports, keyed by their export tag
|
||||
const taggedResolversRef = useRef<
|
||||
Partial<Record<ExportTag, (value: string) => void>>
|
||||
>({})
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Track latest chartXML for restoration after remount
|
||||
const chartXMLRef = useRef<string>("")
|
||||
// Track if diagram has been restored from localStorage
|
||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
||||
setIsDrawioReady(true)
|
||||
// Restore diagram after remount (e.g., theme/UI change)
|
||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||
}
|
||||
}
|
||||
|
||||
const resetDrawioReady = () => {
|
||||
// console.log("[DiagramContext] Resetting DrawIO ready state")
|
||||
hasCalledOnLoadRef.current = false
|
||||
setIsDrawioReady(false)
|
||||
}
|
||||
|
||||
// Update chartXML and its ref together, so callbacks that read the ref
|
||||
// (export handler, autosave) see the new value right away
|
||||
const updateChartXML = (xml: string) => {
|
||||
chartXMLRef.current = xml
|
||||
setChartXML(xml)
|
||||
}
|
||||
// Restore diagram XML when DrawIO becomes ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
||||
useEffect(() => {
|
||||
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
||||
if (!isDrawioReady) {
|
||||
hasDiagramRestoredRef.current = false
|
||||
setCanSaveDiagram(false)
|
||||
return
|
||||
}
|
||||
if (hasDiagramRestoredRef.current) return
|
||||
hasDiagramRestoredRef.current = true
|
||||
|
||||
try {
|
||||
const savedDiagramXml = localStorage.getItem(
|
||||
STORAGE_DIAGRAM_XML_KEY,
|
||||
)
|
||||
if (savedDiagramXml) {
|
||||
// Skip validation for trusted saved diagrams
|
||||
loadDiagram(savedDiagramXml, true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore diagram from localStorage:", error)
|
||||
}
|
||||
|
||||
// Allow saving after restore is complete
|
||||
setTimeout(() => {
|
||||
setCanSaveDiagram(true)
|
||||
}, 500)
|
||||
}, [isDrawioReady])
|
||||
|
||||
// Save diagram XML to localStorage whenever it changes (debounced)
|
||||
useEffect(() => {
|
||||
if (!canSaveDiagram) return
|
||||
if (!chartXML || chartXML.length <= 300) return
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [chartXML, canSaveDiagram])
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
resolver: ((data: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
@@ -117,52 +132,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||
// event) and wait for that result. Resolves to null on timeout, which is
|
||||
// expected occasionally.
|
||||
const requestTaggedExport = (
|
||||
tag: ExportTag,
|
||||
format: "xmlsvg" | "png",
|
||||
timeoutMs: number,
|
||||
) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const finish = (value: string | null) => {
|
||||
clearTimeout(timer)
|
||||
if (taggedResolversRef.current[tag] === finish) {
|
||||
delete taggedResolversRef.current[tag]
|
||||
}
|
||||
resolve(value)
|
||||
// Save current diagram to localStorage (used before theme/UI changes)
|
||||
const saveDiagramToStorage = async (): Promise<void> => {
|
||||
if (!drawioRef.current) return
|
||||
|
||||
try {
|
||||
const currentXml = 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")), 2000),
|
||||
),
|
||||
])
|
||||
|
||||
// Only save if diagram has meaningful content (not empty template)
|
||||
if (currentXml && currentXml.length > 300) {
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
taggedResolversRef.current[tag] = finish
|
||||
drawioRef.current?.exportDiagram({ format, message: tag })
|
||||
})
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
// xmlsvg exports return an SVG data URL
|
||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||
if (svgData?.startsWith("data:image/svg")) {
|
||||
// Update latestSvg so it's available for future saves
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
} catch (error) {
|
||||
console.error("Failed to save diagram to storage:", error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
const captureValidationPng = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(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 = (
|
||||
@@ -171,11 +162,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
): string | null => {
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||
// tool handlers check model XML strictly before it gets here.
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
if (!skipValidation) {
|
||||
const validation = validateAndFixXml(chart, { strict: false })
|
||||
const validation = validateAndFixXml(chart)
|
||||
if (!validation.valid) {
|
||||
console.warn(
|
||||
"[loadDiagram] Validation error:",
|
||||
@@ -194,7 +183,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
updateChartXML(xmlToLoad)
|
||||
setChartXML(xmlToLoad)
|
||||
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
@@ -205,40 +194,33 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Tagged exports (thumbnail, validation PNG, file save) go only to
|
||||
// their own caller, so they never take the result meant for resolverRef
|
||||
const tag = data.message?.message
|
||||
if (tag === "thumbnail" || tag === "validation") {
|
||||
taggedResolversRef.current[tag]?.(data.data)
|
||||
return
|
||||
}
|
||||
if (tag === "save") {
|
||||
saveResolverRef.current.resolver?.(data.data, data.xml)
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
return
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
if (format === "png" || format === "svg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||
const MAX_HISTORY_SIZE = 20
|
||||
if (expectHistoryExportRef.current) {
|
||||
// Store the full multi-page document (extractedXML is only the
|
||||
// first page), so restoring a version keeps every page
|
||||
const historyXml = chartXMLRef.current || extractedXML
|
||||
setDiagramHistory((prev) => {
|
||||
const newHistory = [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: historyXml,
|
||||
xml: extractedXML,
|
||||
},
|
||||
]
|
||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||
@@ -253,20 +235,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 emptyDiagram = BLANK_MXFILE
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
@@ -277,7 +247,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready")
|
||||
@@ -285,40 +254,35 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
resolver: (exportData: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
fileContent =
|
||||
normalizeToMxfile(xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
}) ?? xml
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
|
||||
// Save to localStorage when user manually saves
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
// SVG format
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
@@ -347,14 +311,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
@@ -364,10 +320,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({
|
||||
format: drawioFormat,
|
||||
message: "save",
|
||||
})
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
@@ -393,18 +346,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
chartXML,
|
||||
latestSvg,
|
||||
diagramHistory,
|
||||
setDiagramHistory,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
captureValidationPng,
|
||||
saveDiagramToStorage,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
|
||||
+3
-6
@@ -11,10 +11,7 @@ services:
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Persists admin panel settings (data/settings.json)
|
||||
- ./data:/app/data
|
||||
# environment:
|
||||
# # For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
environment:
|
||||
# For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
depends_on: [drawio]
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 常见问题解答 (FAQ)
|
||||
|
||||
---
|
||||
|
||||
## 1. 无法导出 PDF
|
||||
|
||||
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
|
||||
|
||||
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
|
||||
|
||||
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
|
||||
|
||||
**相关 Issue**: #539, #125
|
||||
|
||||
---
|
||||
|
||||
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
|
||||
|
||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
||||
|
||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
|
||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
|
||||
|
||||
**相关 Issue**: #295, #317
|
||||
|
||||
---
|
||||
|
||||
## 3. 自建模型只思考不画图
|
||||
|
||||
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
|
||||
|
||||
**可能原因**:
|
||||
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
|
||||
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
|
||||
|
||||
**解决方案**: 开启 tool calling,例如 vLLM:
|
||||
```bash
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen3-32B \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**相关 Issue**: #269, #75
|
||||
|
||||
---
|
||||
|
||||
## 4. 上传图片后提示"未提供图片"
|
||||
|
||||
**问题**: 上传图片后,系统显示"未提供图片"错误
|
||||
|
||||
**可能原因**:
|
||||
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
|
||||
|
||||
**解决方案**:
|
||||
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
||||
- 模型名带 `vision` 或 `vl` 的支持图片
|
||||
- 更新到最新版本(v0.4.9+)
|
||||
|
||||
**相关 Issue**: #324, #421, #469
|
||||
+20
-45
@@ -19,9 +19,7 @@
|
||||
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
|
||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
|
||||
|
||||
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
@@ -30,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [目录](#目录)
|
||||
- [示例](#示例)
|
||||
- [功能特性](#功能特性)
|
||||
- [MCP服务器](#mcp服务器)
|
||||
- [MCP服务器(预览)](#mcp服务器预览)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [快速开始](#快速开始)
|
||||
- [在线试用](#在线试用)
|
||||
@@ -39,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [安装](#安装)
|
||||
- [部署](#部署)
|
||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||
- [部署到Vercel](#部署到vercel)
|
||||
- [部署到Vercel(推荐)](#部署到vercel推荐)
|
||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||
- [多提供商支持](#多提供商支持)
|
||||
- [工作原理](#工作原理)
|
||||
- [支持与联系](#支持与联系)
|
||||
- [常见问题](#常见问题)
|
||||
- [Star历史](#star历史)
|
||||
|
||||
## 示例
|
||||
@@ -56,31 +53,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>动画Transformer连接器</strong><br />
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>RAG技术图</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
||||
<strong>GCP架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>React和AWS认证流程</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
||||
<strong>AWS架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>开放式创新</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
||||
<strong>Azure架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫咪素描</strong><br />
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -98,7 +95,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
|
||||
## MCP服务器
|
||||
## MCP服务器(预览)
|
||||
|
||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
||||
|
||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||
|
||||
@@ -124,13 +123,6 @@ 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等客户端配置。
|
||||
|
||||
## 快速开始
|
||||
@@ -187,7 +179,7 @@ npm run dev
|
||||
|
||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
||||
|
||||
### 部署到Vercel
|
||||
### 部署到Vercel(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
@@ -202,19 +194,16 @@ npm run dev
|
||||
|
||||
## 多提供商支持
|
||||
|
||||
- [字节跳动豆包](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)
|
||||
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock(默认)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Google Vertex AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
|
||||
@@ -222,20 +211,10 @@ npm run dev
|
||||
|
||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||
|
||||
### 服务端多模型配置
|
||||
|
||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
||||
|
||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||
|
||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||
|
||||
### 管理面板
|
||||
|
||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
||||
|
||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
||||
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -250,7 +229,7 @@ npm run dev
|
||||
|
||||
## 支持与联系
|
||||
|
||||
**特别感谢[字节跳动豆包](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://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||
|
||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||
|
||||
@@ -258,10 +237,6 @@ npm run dev
|
||||
|
||||
- 邮箱:me[at]jiang.jp
|
||||
|
||||
## 常见问题
|
||||
|
||||
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
||||
|
||||
## Star历史
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 管理面板
|
||||
|
||||
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
|
||||
|
||||
## 启用面板
|
||||
|
||||
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
||||
2. 访问 `/admin` 并登录。
|
||||
|
||||
## 可配置内容
|
||||
|
||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
||||
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
||||
|
||||
## 优先级
|
||||
|
||||
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
||||
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
||||
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
||||
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
||||
+3
-200
@@ -13,7 +13,7 @@
|
||||
|
||||
### 豆包 (字节跳动火山引擎)
|
||||
|
||||
> **免费 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!
|
||||
> **免费 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!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
@@ -149,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
```
|
||||
|
||||
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
|
||||
|
||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||
|
||||
### OpenRouter
|
||||
@@ -176,19 +152,6 @@ AI_PROVIDER=ollama
|
||||
AI_MODEL=llama3.2
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
```bash
|
||||
MODELSCOPE_API_KEY=your_api_key
|
||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
可选的自定义 URL:
|
||||
|
||||
```bash
|
||||
@@ -231,98 +194,6 @@ AI_MODEL=openai/gpt-4o
|
||||
|
||||
从 [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`。
|
||||
@@ -330,77 +201,9 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
|
||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 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)的长文本。
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cannot Export PDF
|
||||
|
||||
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
|
||||
|
||||
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
|
||||
|
||||
**Solution**: Export as image (PNG) first, then print to PDF
|
||||
|
||||
**Related Issues**: #539, #125
|
||||
|
||||
---
|
||||
|
||||
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
|
||||
|
||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
||||
|
||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
||||
|
||||
**Solution**: Must pass via `args` at build time:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
|
||||
|
||||
**Related Issues**: #295, #317
|
||||
|
||||
---
|
||||
|
||||
## 3. Self-hosted Model Only Thinks But Doesn't Draw
|
||||
|
||||
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
|
||||
|
||||
**Possible Causes**:
|
||||
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
|
||||
2. **Tool calling not enabled** - Model service needs tool use configuration
|
||||
|
||||
**Solution**: Enable tool calling, e.g., vLLM:
|
||||
```bash
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen3-32B \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**Related Issues**: #269, #75
|
||||
|
||||
---
|
||||
|
||||
## 4. "No Image Provided" After Uploading Image
|
||||
|
||||
**Problem**: After uploading an image, the system shows "No image provided" error
|
||||
|
||||
**Possible Causes**:
|
||||
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
|
||||
|
||||
**Solution**:
|
||||
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
|
||||
- Models with `vision` or `vl` in name support images
|
||||
- Update to latest version (v0.4.9+)
|
||||
|
||||
**Related Issues**: #324, #421, #469
|
||||
@@ -1,24 +0,0 @@
|
||||
# Admin Panel
|
||||
|
||||
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
|
||||
|
||||
## Enabling the panel
|
||||
|
||||
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
||||
2. Visit `/admin` and sign in.
|
||||
|
||||
## What you can configure
|
||||
|
||||
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
||||
|
||||
## Precedence
|
||||
|
||||
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
|
||||
|
||||
## Notes
|
||||
|
||||
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
|
||||
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
|
||||
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
|
||||
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
|
||||
+3
-215
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
|
||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -33,21 +33,6 @@ Optional 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
|
||||
|
||||
```bash
|
||||
@@ -61,21 +46,6 @@ Optional custom endpoint (for OpenAI-compatible services):
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -83,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
@@ -164,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
```
|
||||
|
||||
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
|
||||
|
||||
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||
|
||||
### OpenRouter
|
||||
@@ -197,19 +158,6 @@ Optional custom URL:
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
```bash
|
||||
MODELSCOPE_API_KEY=your_api_key
|
||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
||||
@@ -246,98 +194,6 @@ Model format uses `provider/model` syntax:
|
||||
|
||||
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
||||
|
||||
### MiniMax
|
||||
|
||||
MiniMax supports two API formats:
|
||||
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
|
||||
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
|
||||
|
||||
```bash
|
||||
MINIMAX_API_KEY=your_api_key
|
||||
AI_MODEL=MiniMax-M3
|
||||
```
|
||||
|
||||
Optional configuration:
|
||||
|
||||
```bash
|
||||
# China mainland, Anthropic-compatible (default)
|
||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||
|
||||
# China mainland, OpenAI-compatible
|
||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||
|
||||
# International, Anthropic-compatible
|
||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||
|
||||
# International, OpenAI-compatible
|
||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
```
|
||||
|
||||
### GLM (Zhipu AI)
|
||||
|
||||
```bash
|
||||
GLM_API_KEY=your_api_key
|
||||
AI_MODEL=glm-4
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
GLM_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Qwen (Alibaba Cloud)
|
||||
|
||||
```bash
|
||||
QWEN_API_KEY=your_api_key
|
||||
AI_MODEL=qwen-turbo
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
QWEN_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Kimi (Moonshot AI)
|
||||
|
||||
```bash
|
||||
KIMI_API_KEY=your_api_key
|
||||
AI_MODEL=kimi-latest
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
KIMI_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Qiniu (Qiniu Cloud)
|
||||
|
||||
```bash
|
||||
QINIU_API_KEY=your_api_key
|
||||
AI_MODEL=your_model_id
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
QINIU_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### MiMo (Xiaomi)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||
@@ -345,77 +201,9 @@ 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`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
```
|
||||
|
||||
## Server-Side Multi-Model Configuration
|
||||
|
||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
|
||||
|
||||
### Configuration Methods
|
||||
|
||||
**Option 1: Environment Variable** (recommended for cloud deployments)
|
||||
|
||||
Set `AI_MODELS_CONFIG` as a JSON string:
|
||||
|
||||
```bash
|
||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
||||
```
|
||||
|
||||
**Option 2: Config File**
|
||||
|
||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
||||
|
||||
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
||||
|
||||
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=doubao
|
||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
```
|
||||
|
||||
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "OpenAI Production",
|
||||
"provider": "openai",
|
||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "Custom DeepSeek",
|
||||
"provider": "deepseek",
|
||||
"models": ["deepseek-chat"],
|
||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Display name (supports multiple configs for same provider) |
|
||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||
| `models` | Yes | List of model IDs |
|
||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||
|
||||
### Notes
|
||||
|
||||
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
|
||||
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
|
||||
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
|
||||
|
||||
## Model Capability Requirements
|
||||
|
||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
||||
|
||||
@@ -22,27 +22,6 @@ cp env.example .env
|
||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
### Using server-side model configuration
|
||||
|
||||
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=your_api_key \
|
||||
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=your_api_key \
|
||||
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
|
||||
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# よくある質問 (FAQ)
|
||||
|
||||
---
|
||||
|
||||
## 1. PDFをエクスポートできない
|
||||
|
||||
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
|
||||
|
||||
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
|
||||
|
||||
**解決策**: まず画像(PNG)としてエクスポートし、その後PDFに印刷する
|
||||
|
||||
**関連Issue**: #539, #125
|
||||
|
||||
---
|
||||
|
||||
## 2. embed.diagrams.netにアクセスできない(オフライン/イントラネットデプロイ)
|
||||
|
||||
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
|
||||
|
||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
||||
|
||||
**解決策**: ビルド時に `args` で渡す必要があります:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
|
||||
|
||||
**関連Issue**: #295, #317
|
||||
|
||||
---
|
||||
|
||||
## 3. 自前モデルが思考するだけで描画しない
|
||||
|
||||
**問題**: ローカルデプロイのモデル(Qwen、LiteLLMなど)が思考過程のみを出力し、図表を生成しない
|
||||
|
||||
**考えられる原因**:
|
||||
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
|
||||
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
|
||||
|
||||
**解決策**: tool callingを有効にする、例えばvLLM:
|
||||
```bash
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen3-32B \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**関連Issue**: #269, #75
|
||||
|
||||
---
|
||||
|
||||
## 4. 画像アップロード後「画像が提供されていません」と表示される
|
||||
|
||||
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
|
||||
|
||||
**考えられる原因**:
|
||||
1. モデルがビジョン機能をサポートしていない(Kimi K2、DeepSeek、Qwenテキストモデルなど)
|
||||
|
||||
**解決策**:
|
||||
- ビジョン対応モデルを使用:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
||||
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
|
||||
- 最新バージョン(v0.4.9+)にアップデート
|
||||
|
||||
**関連Issue**: #324, #421, #469
|
||||
+20
-43
@@ -19,7 +19,7 @@
|
||||
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
|
||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
|
||||
> 注:<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 モデルを導入しました!
|
||||
|
||||
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)
|
||||
- [はじめに](#はじめに)
|
||||
- [オンラインで試す](#オンラインで試す)
|
||||
@@ -37,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [インストール](#インストール)
|
||||
- [デプロイ](#デプロイ)
|
||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
||||
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
|
||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||
- [仕組み](#仕組み)
|
||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||
- [よくある質問](#よくある質問)
|
||||
- [スター履歴](#スター履歴)
|
||||
|
||||
## 例
|
||||
@@ -54,31 +53,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>RAG技術ダイアグラム</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||
<strong>GCPアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>ReactとAWSによる認証</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||
<strong>AWSアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>オープンイノベーション</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||
<strong>Azureアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫のスケッチ</strong><br />
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -96,7 +95,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
## MCPサーバー
|
||||
## MCPサーバー(プレビュー)
|
||||
|
||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
||||
|
||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||
|
||||
@@ -122,13 +123,6 @@ 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などのクライアント設定も含む)。
|
||||
|
||||
## はじめに
|
||||
@@ -186,7 +180,7 @@ npm run dev
|
||||
|
||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
||||
|
||||
### Vercelへのデプロイ
|
||||
### Vercelへのデプロイ(推奨)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
@@ -201,19 +195,16 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
|
||||
## マルチプロバイダーサポート
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
- AWS Bedrock(デフォルト)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Google Vertex AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
|
||||
@@ -221,20 +212,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
📖 **[詳細なプロバイダー設定ガイド](./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を推奨します。
|
||||
|
||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
### 管理パネル
|
||||
|
||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
||||
|
||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
||||
|
||||
|
||||
## 仕組み
|
||||
|
||||
@@ -249,7 +230,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
## サポート&お問い合わせ
|
||||
|
||||
**デモサイトの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万トークンが無料でもらえます!
|
||||
**デモサイトの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万トークンが無料でもらえます!
|
||||
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||
|
||||
@@ -257,10 +238,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
- メール:me[at]jiang.jp
|
||||
|
||||
## よくある質問
|
||||
|
||||
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 管理パネル
|
||||
|
||||
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
|
||||
|
||||
## パネルの有効化
|
||||
|
||||
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
||||
2. `/admin` にアクセスしてサインインします。
|
||||
|
||||
## 設定できる項目
|
||||
|
||||
1. **Models(モデル)** — アプリ内のモデル設定と同じ 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_*` 変数はビルド時に固定され、パネルでは変更できません。
|
||||
+3
-200
@@ -13,7 +13,7 @@
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
||||
> **無料トークン**: [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万トークンが無料で入手できます!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
||||
AI_MODEL=claude-sonnet-4-5-20250514
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
@@ -149,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
```
|
||||
|
||||
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
|
||||
|
||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||
|
||||
### OpenRouter
|
||||
@@ -182,19 +158,6 @@ AI_MODEL=llama3.2
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
```bash
|
||||
MODELSCOPE_API_KEY=your_api_key
|
||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
||||
@@ -231,98 +194,6 @@ AI_MODEL=openai/gpt-4o
|
||||
|
||||
[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` を設定する必要はありません。
|
||||
@@ -330,77 +201,9 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
|
||||
管理者は、ユーザーが個人の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)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
||||
|
||||
@@ -11,6 +11,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
## Networking & Infrastructure
|
||||
@@ -19,6 +20,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
|---------|--------|--------|-------------|------|
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -28,6 +30,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| 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) |
|
||||
|
||||
## General Diagrams
|
||||
@@ -45,7 +48,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| 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
|
||||
|
||||
@@ -71,5 +73,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| 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: 30 libraries, 4,184 shapes**
|
||||
**Total: 33 libraries, 4,281 shapes**
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
# material_design
|
||||
|
||||
**Type:** SVG images (Google Material Icons CDN)
|
||||
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
|
||||
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
Replace `{icon_name}` with any icon name from the list below.
|
||||
|
||||
## action (115)
|
||||
|
||||
- `account_balance`
|
||||
- `account_balance_wallet`
|
||||
- `account_box`
|
||||
- `account_circle`
|
||||
- `add_shopping_cart`
|
||||
- `admin_panel_settings`
|
||||
- `analytics`
|
||||
- `arrow_right_alt`
|
||||
- `article`
|
||||
- `assessment`
|
||||
- `assignment`
|
||||
- `assignment_ind`
|
||||
- `assignment_turned_in`
|
||||
- `autorenew`
|
||||
- `bookmark`
|
||||
- `bookmark_border`
|
||||
- `build`
|
||||
- `calendar_month`
|
||||
- `calendar_today`
|
||||
- `card_giftcard`
|
||||
- `check_circle`
|
||||
- `check_circle_outline`
|
||||
- `code`
|
||||
- `contact_support`
|
||||
- `credit_card`
|
||||
- `dashboard`
|
||||
- `date_range`
|
||||
- `delete`
|
||||
- `delete_forever`
|
||||
- `delete_outline`
|
||||
- `description`
|
||||
- `dns`
|
||||
- `done`
|
||||
- `done_all`
|
||||
- `done_outline`
|
||||
- `drag_indicator`
|
||||
- `event`
|
||||
- `exit_to_app`
|
||||
- `explore`
|
||||
- `face`
|
||||
- `fact_check`
|
||||
- `favorite`
|
||||
- `favorite_border`
|
||||
- `feedback`
|
||||
- `filter_alt`
|
||||
- `fingerprint`
|
||||
- `flight_takeoff`
|
||||
- `grade`
|
||||
- `help`
|
||||
- `help_outline`
|
||||
- `highlight_off`
|
||||
- `history`
|
||||
- `home`
|
||||
- `info`
|
||||
- `label`
|
||||
- `language`
|
||||
- `launch`
|
||||
- `leaderboard`
|
||||
- `lightbulb`
|
||||
- `list`
|
||||
- `lock`
|
||||
- `lock_open`
|
||||
- `login`
|
||||
- `logout`
|
||||
- `manage_accounts`
|
||||
- `note_add`
|
||||
- `open_in_full`
|
||||
- `open_in_new`
|
||||
- `paid`
|
||||
- `payment`
|
||||
- `pending`
|
||||
- `pending_actions`
|
||||
- `perm_identity`
|
||||
- `pets`
|
||||
- `power_settings_new`
|
||||
- `preview`
|
||||
- `print`
|
||||
- `published_with_changes`
|
||||
- `question_answer`
|
||||
- `receipt`
|
||||
- `reorder`
|
||||
- `report_problem`
|
||||
- `room`
|
||||
- `savings`
|
||||
- `schedule`
|
||||
- `search`
|
||||
- `settings`
|
||||
- `shopping_bag`
|
||||
- `shopping_basket`
|
||||
- `shopping_cart`
|
||||
- `star_rate`
|
||||
- `stars`
|
||||
- `store`
|
||||
- `supervisor_account`
|
||||
- `swap_horiz`
|
||||
- `sync_alt`
|
||||
- `task_alt`
|
||||
- `thumb_up`
|
||||
- `thumb_up_off_alt`
|
||||
- `timeline`
|
||||
- `tips_and_updates`
|
||||
- `today`
|
||||
- `touch_app`
|
||||
- `trending_up`
|
||||
- `update`
|
||||
- `verified`
|
||||
- `verified_user`
|
||||
- `view_in_ar`
|
||||
- `view_list`
|
||||
- `visibility`
|
||||
- `visibility_off`
|
||||
- `watch_later`
|
||||
- `work`
|
||||
- `work_outline`
|
||||
- `zoom_in`
|
||||
|
||||
## alert (4)
|
||||
|
||||
- `error`
|
||||
- `error_outline`
|
||||
- `warning`
|
||||
- `warning_amber`
|
||||
|
||||
## av (12)
|
||||
|
||||
- `library_books`
|
||||
- `mic`
|
||||
- `pause`
|
||||
- `play_arrow`
|
||||
- `play_circle`
|
||||
- `play_circle_filled`
|
||||
- `play_circle_outline`
|
||||
- `replay`
|
||||
- `skip_next`
|
||||
- `videocam`
|
||||
- `volume_off`
|
||||
- `volume_up`
|
||||
|
||||
## communication (13)
|
||||
|
||||
- `alternate_email`
|
||||
- `business`
|
||||
- `call`
|
||||
- `chat`
|
||||
- `chat_bubble_outline`
|
||||
- `email`
|
||||
- `forum`
|
||||
- `list_alt`
|
||||
- `location_on`
|
||||
- `mail_outline`
|
||||
- `phone`
|
||||
- `qr_code_scanner`
|
||||
- `vpn_key`
|
||||
|
||||
## content (27)
|
||||
|
||||
- `add`
|
||||
- `add_box`
|
||||
- `add_circle`
|
||||
- `add_circle_outline`
|
||||
- `block`
|
||||
- `bolt`
|
||||
- `calculate`
|
||||
- `clear`
|
||||
- `content_copy`
|
||||
- `create`
|
||||
- `filter_list`
|
||||
- `flag`
|
||||
- `how_to_reg`
|
||||
- `insights`
|
||||
- `inventory`
|
||||
- `inventory_2`
|
||||
- `link`
|
||||
- `mail`
|
||||
- `push_pin`
|
||||
- `remove`
|
||||
- `remove_circle`
|
||||
- `remove_circle_outline`
|
||||
- `reply`
|
||||
- `save`
|
||||
- `send`
|
||||
- `sort`
|
||||
- `undo`
|
||||
|
||||
## device (9)
|
||||
|
||||
- `dark_mode`
|
||||
- `devices`
|
||||
- `light_mode`
|
||||
- `password`
|
||||
- `restart_alt`
|
||||
- `sell`
|
||||
- `signal_cellular_alt`
|
||||
- `summarize`
|
||||
- `task`
|
||||
|
||||
## editor (9)
|
||||
|
||||
- `attach_file`
|
||||
- `attach_money`
|
||||
- `bar_chart`
|
||||
- `checklist`
|
||||
- `edit_note`
|
||||
- `format_list_bulleted`
|
||||
- `mode_edit`
|
||||
- `monetization_on`
|
||||
- `post_add`
|
||||
|
||||
## file (8)
|
||||
|
||||
- `cloud_upload`
|
||||
- `download`
|
||||
- `file_download`
|
||||
- `file_upload`
|
||||
- `folder`
|
||||
- `folder_open`
|
||||
- `grid_view`
|
||||
- `upload_file`
|
||||
|
||||
## hardware (6)
|
||||
|
||||
- `computer`
|
||||
- `keyboard_arrow_down`
|
||||
- `keyboard_arrow_right`
|
||||
- `phone_iphone`
|
||||
- `security`
|
||||
- `smartphone`
|
||||
|
||||
## image (16)
|
||||
|
||||
- `add_a_photo`
|
||||
- `auto_awesome`
|
||||
- `auto_stories`
|
||||
- `circle`
|
||||
- `collections`
|
||||
- `edit`
|
||||
- `image`
|
||||
- `navigate_before`
|
||||
- `navigate_next`
|
||||
- `palette`
|
||||
- `photo_camera`
|
||||
- `picture_as_pdf`
|
||||
- `receipt_long`
|
||||
- `remove_red_eye`
|
||||
- `timer`
|
||||
- `tune`
|
||||
|
||||
## maps (11)
|
||||
|
||||
- `badge`
|
||||
- `category`
|
||||
- `directions_car`
|
||||
- `local_fire_department`
|
||||
- `local_offer`
|
||||
- `local_shipping`
|
||||
- `map`
|
||||
- `menu_book`
|
||||
- `place`
|
||||
- `restaurant`
|
||||
- `volunteer_activism`
|
||||
|
||||
## navigation (29)
|
||||
|
||||
- `apps`
|
||||
- `arrow_back`
|
||||
- `arrow_back_ios`
|
||||
- `arrow_back_ios_new`
|
||||
- `arrow_downward`
|
||||
- `arrow_drop_down`
|
||||
- `arrow_drop_up`
|
||||
- `arrow_forward`
|
||||
- `arrow_forward_ios`
|
||||
- `arrow_right`
|
||||
- `arrow_upward`
|
||||
- `campaign`
|
||||
- `cancel`
|
||||
- `check`
|
||||
- `chevron_left`
|
||||
- `chevron_right`
|
||||
- `close`
|
||||
- `double_arrow`
|
||||
- `east`
|
||||
- `expand_less`
|
||||
- `expand_more`
|
||||
- `fullscreen`
|
||||
- `menu`
|
||||
- `menu_open`
|
||||
- `more_horiz`
|
||||
- `more_vert`
|
||||
- `payments`
|
||||
- `refresh`
|
||||
- `unfold_more`
|
||||
|
||||
## notification (6)
|
||||
|
||||
- `account_tree`
|
||||
- `event_available`
|
||||
- `priority_high`
|
||||
- `support_agent`
|
||||
- `sync`
|
||||
- `wifi`
|
||||
|
||||
## places (2)
|
||||
|
||||
- `apartment`
|
||||
- `storefront`
|
||||
|
||||
## search (2)
|
||||
|
||||
- `feed`
|
||||
- `manage_search`
|
||||
|
||||
## social (23)
|
||||
|
||||
- `construction`
|
||||
- `emoji_emotions`
|
||||
- `emoji_events`
|
||||
- `engineering`
|
||||
- `group`
|
||||
- `group_add`
|
||||
- `groups`
|
||||
- `health_and_safety`
|
||||
- `notifications`
|
||||
- `notifications_active`
|
||||
- `notifications_none`
|
||||
- `people`
|
||||
- `people_alt`
|
||||
- `person`
|
||||
- `person_add`
|
||||
- `person_outline`
|
||||
- `psychology`
|
||||
- `public`
|
||||
- `school`
|
||||
- `share`
|
||||
- `thumb_up_alt`
|
||||
- `travel_explore`
|
||||
- `water_drop`
|
||||
|
||||
## toggle (8)
|
||||
|
||||
- `check_box`
|
||||
- `check_box_outline_blank`
|
||||
- `radio_button_checked`
|
||||
- `radio_button_unchecked`
|
||||
- `star`
|
||||
- `star_border`
|
||||
- `star_outline`
|
||||
- `toggle_on`
|
||||
|
||||
Total: 300 icons (top by popularity from 2100+ available)
|
||||
@@ -67,62 +67,41 @@ const MODEL_ALIASES: Record<string, string> = {
|
||||
"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 JSON response
|
||||
* Create standardized response with CORS headers
|
||||
*/
|
||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...CORS_HEADERS,
|
||||
...extraHeaders,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
||||
// function, so no CORS headers are sent: other sites' pages can't call it
|
||||
// from a browser and spend the deployment's Edge AI quota.
|
||||
// Same rule as lib/access-code.ts, but reading the edge function's env.
|
||||
// No codes configured (or env unavailable) means no check.
|
||||
function hasValidAccessCode(request: Request, env: any): boolean {
|
||||
const accessCodes: string[] =
|
||||
env?.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code: string) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length === 0) return true
|
||||
const accessCode = request.headers.get("x-access-code")
|
||||
return !!accessCode && accessCodes.includes(accessCode)
|
||||
/**
|
||||
* Handle OPTIONS request for CORS preflight
|
||||
*/
|
||||
function handleOptionsRequest(): Response {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function onRequest({ request, env }: any) {
|
||||
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||
// preflight, which fails without CORS headers
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
!request.headers.get("content-type")?.includes("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,
|
||||
)
|
||||
if (request.method === "OPTIONS") {
|
||||
return handleOptionsRequest()
|
||||
}
|
||||
|
||||
request.headers.delete("accept-encoding")
|
||||
@@ -174,7 +153,7 @@ export async function onRequest({ request, env }: any) {
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
400,
|
||||
429,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,6 +216,7 @@ export async function onRequest({ request, env }: any) {
|
||||
"Cache-Control": "no-cache, no-store, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
Connection: "keep-alive",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -10,13 +10,8 @@ directories:
|
||||
afterPack: ./scripts/afterPack.cjs
|
||||
|
||||
files:
|
||||
- from: dist-electron
|
||||
to: dist-electron
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: .
|
||||
filter:
|
||||
- package.json
|
||||
- dist-electron/**/*
|
||||
- "!node_modules"
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
@@ -42,11 +37,10 @@ mac:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
# 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
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: resources/entitlements.mac.plist
|
||||
entitlementsInherit: resources/entitlements.mac.plist
|
||||
|
||||
dmg:
|
||||
contents:
|
||||
@@ -95,10 +89,6 @@ linux:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
|
||||
# Publish configuration (optional)
|
||||
publish:
|
||||
|
||||
Vendored
+5
-36
@@ -25,25 +25,6 @@ interface ApplyPresetResult {
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/** Proxy configuration interface */
|
||||
interface ProxyConfig {
|
||||
httpProxy?: string
|
||||
httpsProxy?: string
|
||||
}
|
||||
|
||||
/** Result of setting proxy */
|
||||
interface SetProxyResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
devMode?: boolean
|
||||
}
|
||||
|
||||
/** Result of setting user locale */
|
||||
interface SetUserLocaleResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Main window Electron API */
|
||||
@@ -60,16 +41,10 @@ declare global {
|
||||
maximize: () => void
|
||||
/** Close the window */
|
||||
close: () => void
|
||||
/** Get proxy configuration */
|
||||
getProxy: () => Promise<ProxyConfig>
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||
/** Get user's preferred locale */
|
||||
getUserLocale: () => Promise<
|
||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
/** Open file dialog and return file path */
|
||||
openFile: () => Promise<string | null>
|
||||
/** Save data to file via save dialog */
|
||||
saveFile: (data: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
@@ -96,10 +71,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ApplyPresetResult,
|
||||
ConfigPreset,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
}
|
||||
export { ConfigPreset, ApplyPresetResult }
|
||||
|
||||
+72
-110
@@ -12,12 +12,11 @@ import {
|
||||
getCurrentPresetId,
|
||||
setCurrentPreset,
|
||||
} from "./config-manager"
|
||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { showSettingsWindow } from "./settings-window"
|
||||
|
||||
/**
|
||||
* Build and set the application menu with i18n support
|
||||
* Build and set the application menu
|
||||
*/
|
||||
export function buildAppMenu(): void {
|
||||
const template = getMenuTemplate()
|
||||
@@ -26,71 +25,18 @@ export function buildAppMenu(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the menu (call this when presets change or language changes)
|
||||
* Rebuild the menu (call this when presets change)
|
||||
*/
|
||||
export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
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)
|
||||
|
||||
// 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
|
||||
* Get the menu template
|
||||
*/
|
||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
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[] = []
|
||||
|
||||
// macOS app menu
|
||||
@@ -98,10 +44,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" }, // System-translated
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t.settings,
|
||||
label: "Settings...",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
@@ -109,26 +55,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" }, // System-translated
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" }, // System-translated
|
||||
{ role: "hideOthers" }, // System-translated
|
||||
{ role: "unhide" }, // System-translated
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" }, // System-translated
|
||||
{ role: "quit" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// File menu
|
||||
template.push({
|
||||
label: t.file,
|
||||
label: "File",
|
||||
submenu: [
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t.settings,
|
||||
label: "Settings",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
@@ -137,76 +83,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||
isMac ? { role: "close" } : { role: "quit" },
|
||||
],
|
||||
})
|
||||
|
||||
// Edit menu
|
||||
template.push({
|
||||
label: t.edit,
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" }, // System-translated
|
||||
{ role: "redo" }, // System-translated
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" }, // System-translated
|
||||
{ role: "copy" }, // System-translated
|
||||
{ role: "paste" }, // System-translated
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
role: "pasteAndMatchStyle",
|
||||
} as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||
} as MenuItemConstructorOptions,
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: [
|
||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
// View menu
|
||||
template.push({
|
||||
label: t.view,
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" }, // System-translated
|
||||
{ role: "forceReload" }, // System-translated
|
||||
{ role: "toggleDevTools" }, // System-translated
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" }, // System-translated
|
||||
{ role: "zoomIn" }, // System-translated
|
||||
{ role: "zoomOut" }, // System-translated
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" }, // System-translated
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
})
|
||||
|
||||
// Configuration menu with presets
|
||||
template.push(buildConfigMenu(t))
|
||||
template.push(buildConfigMenu())
|
||||
|
||||
// Window menu
|
||||
template.push({
|
||||
label: t.window,
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" }, // System-translated
|
||||
{ role: "zoom" }, // System-translated
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
||||
{ role: "front" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
||||
: [{ role: "close" } as MenuItemConstructorOptions]),
|
||||
],
|
||||
})
|
||||
|
||||
// Help menu
|
||||
template.push({
|
||||
label: t.help,
|
||||
label: "Help",
|
||||
submenu: [
|
||||
{
|
||||
label: t.documentation,
|
||||
label: "Documentation",
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||
@@ -214,7 +160,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t.reportIssue,
|
||||
label: "Report Issue",
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||
@@ -230,9 +176,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
/**
|
||||
* Build the Configuration menu with presets
|
||||
*/
|
||||
function buildConfigMenu(
|
||||
t: ReturnType<typeof getMenuTranslations>,
|
||||
): MenuItemConstructorOptions {
|
||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
||||
const presets = getAllPresets()
|
||||
const currentPresetId = getCurrentPresetId()
|
||||
|
||||
@@ -241,24 +185,42 @@ function buildConfigMenu(
|
||||
type: "radio",
|
||||
checked: preset.id === currentPresetId,
|
||||
click: async () => {
|
||||
try {
|
||||
await switchPreset(preset.id)
|
||||
} catch (error) {
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(preset.id)
|
||||
|
||||
if (env) {
|
||||
try {
|
||||
await restartNextServer()
|
||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
||||
} 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 {
|
||||
label: t.configuration,
|
||||
label: "Configuration",
|
||||
submenu: [
|
||||
...(presetItems.length > 0
|
||||
? [
|
||||
{ label: t.switchPreset, enabled: false },
|
||||
{ label: "Switch Preset", enabled: false },
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
...presetItems,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
@@ -267,8 +229,8 @@ function buildConfigMenu(
|
||||
{
|
||||
label:
|
||||
presetItems.length > 0
|
||||
? t.managePresets
|
||||
: t.addConfigurationPreset,
|
||||
? "Manage Presets..."
|
||||
: "Add Configuration Preset...",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
|
||||
+71
-152
@@ -1,11 +1,5 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, safeStorage } from "electron"
|
||||
|
||||
@@ -36,9 +30,7 @@ let hasWarnedAboutPlaintext = false
|
||||
* Warns if encryption is not available (API key stored in plaintext)
|
||||
*/
|
||||
function encryptValue(value: string): string {
|
||||
// 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)) {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -69,7 +61,6 @@ function encryptValue(value: string): string {
|
||||
/**
|
||||
* Decrypt a sensitive value using safeStorage
|
||||
* 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 {
|
||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
@@ -146,7 +137,6 @@ interface ConfigPresetsFile {
|
||||
version: 1
|
||||
currentPresetId: string | null
|
||||
presets: ConfigPreset[]
|
||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = "config-presets.json"
|
||||
@@ -171,7 +161,6 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,20 +177,10 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
return data
|
||||
} catch (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) {
|
||||
console.error("Failed to back up config presets:", renameError)
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,11 +208,7 @@ export function savePresets(data: ConfigPresetsFile): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// 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)
|
||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save config presets:", error)
|
||||
throw error
|
||||
@@ -329,10 +304,9 @@ export function deletePreset(id: string): boolean {
|
||||
|
||||
data.presets.splice(index, 1)
|
||||
|
||||
// Clear current preset (and its env vars) if it was deleted
|
||||
// Clear current preset if it was deleted
|
||||
if (data.currentPresetId === id) {
|
||||
data.currentPresetId = null
|
||||
setPresetEnv(null)
|
||||
}
|
||||
|
||||
savePresets(data)
|
||||
@@ -345,15 +319,13 @@ export function deletePreset(id: string): boolean {
|
||||
export function setCurrentPreset(id: string | null): boolean {
|
||||
const data = loadPresets()
|
||||
|
||||
let preset: ConfigPreset | null = null
|
||||
if (id !== null) {
|
||||
preset = data.presets.find((p) => p.id === id) || null
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
if (!preset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setPresetEnv(preset)
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
return true
|
||||
@@ -379,34 +351,85 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
apiKey: "SILICONFLOW_API_KEY",
|
||||
baseUrl: "SILICONFLOW_BASE_URL",
|
||||
},
|
||||
modelscope: {
|
||||
apiKey: "MODELSCOPE_API_KEY",
|
||||
baseUrl: "MODELSCOPE_BASE_URL",
|
||||
},
|
||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||
// bedrock doesn't use API keys in the same way
|
||||
// bedrock and ollama don't use API keys in the same way
|
||||
bedrock: { apiKey: "", baseUrl: "" },
|
||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a preset's config to environment variables
|
||||
* 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: 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
|
||||
*/
|
||||
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||
export function getCurrentPresetEnv(): Record<string, string> {
|
||||
const preset = getCurrentPreset()
|
||||
if (!preset) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
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
|
||||
else if (
|
||||
if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
@@ -435,107 +458,3 @@ function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -48,16 +48,12 @@ function loadEnvFromFile(filePath: string): void {
|
||||
const key = trimmed.slice(0, equalIndex).trim()
|
||||
let value = trimmed.slice(equalIndex + 1).trim()
|
||||
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
|
||||
if (closingQuote > 0) {
|
||||
// Quoted value: keep what's inside the quotes and drop
|
||||
// anything after them (e.g. a comment)
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment")
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
|
||||
+18
-51
@@ -1,17 +1,11 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { applyProxyToEnv } from "./proxy-manager"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import {
|
||||
createWindow,
|
||||
getAppUrl,
|
||||
getMainWindow,
|
||||
isAppUrl,
|
||||
} from "./window-manager"
|
||||
import { createWindow, getMainWindow } from "./window-manager"
|
||||
|
||||
// Single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
@@ -30,17 +24,16 @@ if (!gotTheLock) {
|
||||
// Load environment variables from .env files
|
||||
loadEnvFile()
|
||||
|
||||
// Apply proxy settings from saved config
|
||||
applyProxyToEnv()
|
||||
// Apply saved preset environment variables (overrides .env)
|
||||
const presetEnv = getCurrentPresetEnv()
|
||||
for (const [key, value] of Object.entries(presetEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
let serverUrl: string | null = null
|
||||
|
||||
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
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
@@ -49,7 +42,6 @@ if (!gotTheLock) {
|
||||
buildAppMenu()
|
||||
|
||||
try {
|
||||
let serverUrl: string
|
||||
if (isDev) {
|
||||
// Development: use the dev server URL
|
||||
serverUrl =
|
||||
@@ -73,9 +65,8 @@ if (!gotTheLock) {
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
const appUrl = getAppUrl()
|
||||
if (appUrl) {
|
||||
createWindow(appUrl)
|
||||
if (serverUrl) {
|
||||
createWindow(serverUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -92,47 +83,23 @@ if (!gotTheLock) {
|
||||
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
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (isInAppUrl(url)) {
|
||||
// Allow diagrams.net iframe
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost")
|
||||
) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
// Open other links in external browser
|
||||
if (isWebUrl(url)) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
shell.openExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
// Clicking a plain link would otherwise replace the app page with
|
||||
// an external site that keeps the preload API
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isInAppUrl(event.url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (isWebUrl(event.url)) {
|
||||
shell.openExternal(event.url)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+91
-144
@@ -1,25 +1,16 @@
|
||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
type ConfigPreset,
|
||||
createPreset,
|
||||
deletePreset,
|
||||
getAllPresets,
|
||||
getCurrentPreset,
|
||||
getCurrentPresetId,
|
||||
getUserLocale,
|
||||
setCurrentPreset,
|
||||
setUserLocale,
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import {
|
||||
applyProxyToEnv,
|
||||
getProxyConfig,
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-manager"
|
||||
import { isAppUrl } from "./window-manager"
|
||||
|
||||
/**
|
||||
* Allowed configuration keys for presets
|
||||
@@ -48,32 +39,13 @@ function sanitizePresetConfig(
|
||||
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
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
// ==================== App Info ====================
|
||||
|
||||
handle("get-version", () => {
|
||||
ipcMain.handle("get-version", () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
@@ -98,30 +70,84 @@ export function registerIpcHandlers(): void {
|
||||
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 ====================
|
||||
|
||||
handle("config-presets:get-all", () => {
|
||||
ipcMain.handle("config-presets:get-all", () => {
|
||||
return getAllPresets()
|
||||
})
|
||||
|
||||
handle("config-presets:get-current", () => {
|
||||
ipcMain.handle("config-presets:get-current", () => {
|
||||
return getCurrentPreset()
|
||||
})
|
||||
|
||||
handle("config-presets:get-current-id", () => {
|
||||
ipcMain.handle("config-presets:get-current-id", () => {
|
||||
return getCurrentPresetId()
|
||||
})
|
||||
|
||||
handle(
|
||||
ipcMain.handle(
|
||||
"config-presets:save",
|
||||
async (
|
||||
(
|
||||
_event,
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||
id?: string
|
||||
},
|
||||
) => {
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -130,48 +156,42 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
if (preset.id) {
|
||||
// Update existing preset
|
||||
const updated = updatePreset(preset.id, {
|
||||
return updatePreset(preset.id, {
|
||||
name: preset.name.trim(),
|
||||
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
|
||||
const created = createPreset({
|
||||
return createPreset({
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
rebuildAppMenu()
|
||||
return created
|
||||
},
|
||||
)
|
||||
|
||||
handle("config-presets:delete", async (_event, id: string) => {
|
||||
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:delete", (_event, id: string) => {
|
||||
return deletePreset(id)
|
||||
})
|
||||
|
||||
handle("config-presets:apply", async (_event, id: string) => {
|
||||
ipcMain.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 {
|
||||
const env = await switchPreset(id)
|
||||
// In development mode, electron-dev.mjs restarts Next.js
|
||||
return app.isPackaged
|
||||
? { success: true, env }
|
||||
: { success: true, env, devMode: true }
|
||||
await restartNextServer()
|
||||
return { success: true, env }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -183,83 +203,10 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
})
|
||||
|
||||
// ==================== Proxy Settings ====================
|
||||
|
||||
handle("get-proxy", () => {
|
||||
return getProxyConfig()
|
||||
})
|
||||
|
||||
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||
const isOptionalString = (value: unknown) =>
|
||||
value === undefined || typeof value === "string"
|
||||
if (
|
||||
typeof config !== "object" ||
|
||||
config === null ||
|
||||
!isOptionalString(config.httpProxy) ||
|
||||
!isOptionalString(config.httpsProxy)
|
||||
) {
|
||||
return { success: false, error: "Invalid proxy settings" }
|
||||
}
|
||||
|
||||
try {
|
||||
// Save config to file
|
||||
saveProxyConfig({
|
||||
httpProxy: config.httpProxy,
|
||||
httpsProxy: config.httpsProxy,
|
||||
})
|
||||
|
||||
// Apply to current process environment
|
||||
applyProxyToEnv()
|
||||
|
||||
if (!app.isPackaged) {
|
||||
// In development, env vars are already applied
|
||||
// Next.js dev server may need manual restart
|
||||
return { success: true, devMode: true }
|
||||
}
|
||||
|
||||
// Production: restart Next.js server to pick up new env vars
|
||||
await restartNextServer()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to apply proxy settings",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== User Locale ====================
|
||||
|
||||
handle("get-user-locale", () => {
|
||||
return getUserLocale()
|
||||
})
|
||||
|
||||
handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
}
|
||||
|
||||
try {
|
||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||
// Rebuild the menu to reflect the new locale
|
||||
rebuildAppMenu()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to set locale",
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle(
|
||||
"config-presets:set-current",
|
||||
(_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -6,22 +6,10 @@ import {
|
||||
getAllocatedPort,
|
||||
getServerUrl,
|
||||
isPortAvailable,
|
||||
saveServerPort,
|
||||
} from "./port-manager"
|
||||
import { setAppUrl } from "./window-manager"
|
||||
|
||||
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
|
||||
* In packaged app: resources/standalone
|
||||
@@ -57,11 +45,7 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||
* Start the Next.js standalone server using Electron's utilityProcess
|
||||
* This API is designed for running Node.js code in the background
|
||||
*/
|
||||
export function startNextServer(): Promise<string> {
|
||||
return runExclusive(startServer)
|
||||
}
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
export async function startNextServer(): Promise<string> {
|
||||
const resourcePath = getResourcePath()
|
||||
const serverPath = path.join(resourcePath, "server.js")
|
||||
|
||||
@@ -84,16 +68,7 @@ async function startServer(): Promise<string> {
|
||||
const env: Record<string, string> = {
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
// The preset keys are the user's own, not a server's
|
||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||
}
|
||||
|
||||
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
||||
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
||||
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
||||
HOSTNAME: "localhost",
|
||||
}
|
||||
|
||||
// Set cache directory to a writable location (user's app data folder)
|
||||
@@ -110,80 +85,43 @@ async function startServer(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: log proxy-related env vars
|
||||
console.log("Proxy env vars being passed to server:", {
|
||||
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
|
||||
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
|
||||
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
|
||||
})
|
||||
|
||||
// Use Electron's utilityProcess API for running Node.js in background
|
||||
// This is the recommended way to run Node.js code in Electron
|
||||
const proc = utilityProcess.fork(serverPath, [], {
|
||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||
cwd: resourcePath,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
})
|
||||
serverProcess = proc
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
serverProcess.stdout?.on("data", (data) => {
|
||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
serverProcess.stderr?.on("data", (data) => {
|
||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
serverProcess.on("exit", (code) => {
|
||||
console.log(`Next.js server exited with code ${code}`)
|
||||
// An old server can exit after a new one started; keep the new one
|
||||
if (serverProcess === proc) {
|
||||
serverProcess = null
|
||||
}
|
||||
serverProcess = null
|
||||
})
|
||||
|
||||
const url = getServerUrl()
|
||||
await waitForServer(url)
|
||||
console.log(`Next.js server started at ${url}`)
|
||||
saveServerPort(port)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Next.js server process and wait for it to exit
|
||||
* Stop the Next.js server process
|
||||
*/
|
||||
export async function stopNextServer(): Promise<void> {
|
||||
const proc = serverProcess
|
||||
if (!proc) {
|
||||
return
|
||||
export function stopNextServer(): void {
|
||||
if (serverProcess) {
|
||||
console.log("Stopping Next.js server...")
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
}
|
||||
console.log("Stopping Next.js server...")
|
||||
serverProcess = null
|
||||
|
||||
// Resolves true when the process exits, false after the timeout
|
||||
const waitForExit = (ms: number) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
proc.once("exit", () => resolve(true))
|
||||
setTimeout(() => resolve(false), ms)
|
||||
})
|
||||
|
||||
proc.kill()
|
||||
|
||||
// Next.js waits for open requests (e.g. a streaming reply) before it
|
||||
// exits, so force kill it if it is still running after 5 seconds
|
||||
if (!(await waitForExit(5000)) && proc.pid) {
|
||||
console.warn("Next.js server did not exit in time, force killing it")
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL")
|
||||
} catch (error) {
|
||||
console.error("Failed to force kill Next.js server:", error)
|
||||
}
|
||||
await waitForExit(2000)
|
||||
}
|
||||
|
||||
// Additional wait for OS to release port
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,19 +147,15 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
||||
/**
|
||||
* Restart the Next.js server with new environment variables
|
||||
*/
|
||||
export function restartNextServer(): Promise<string> {
|
||||
return runExclusive(async () => {
|
||||
console.log("Restarting Next.js server...")
|
||||
export async function restartNextServer(): Promise<string> {
|
||||
console.log("Restarting Next.js server...")
|
||||
|
||||
// Stop the current server and wait for it to exit
|
||||
await stopNextServer()
|
||||
// Stop the current server
|
||||
stopNextServer()
|
||||
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
|
||||
// Start the server again, and follow it if it moved to another port
|
||||
const url = await startServer()
|
||||
setAppUrl(url)
|
||||
return url
|
||||
})
|
||||
// Start the server again
|
||||
return startNextServer()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
@@ -11,11 +9,9 @@ import { app } from "electron"
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Legacy production port — tried first to preserve localStorage for existing users
|
||||
legacyProduction: 61337,
|
||||
// New production port below the ephemeral range (49152-65535)
|
||||
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
||||
production: 13370,
|
||||
// Production mode uses fixed port (61337) to preserve localStorage
|
||||
// Falls back to sequential ports if unavailable
|
||||
production: 61337,
|
||||
// Maximum attempts to find an available port (fallback)
|
||||
maxAttempts: 100,
|
||||
}
|
||||
@@ -25,50 +21,13 @@ const PORT_CONFIG = {
|
||||
*/
|
||||
let allocatedPort: number | null = null
|
||||
|
||||
/**
|
||||
* File that remembers the production port from the last launch, so the app
|
||||
* keeps the same origin (and its localStorage) instead of switching between
|
||||
* the legacy and new port depending on which one is free at startup
|
||||
*/
|
||||
function getSavedPortPath(): string {
|
||||
return path.join(app.getPath("userData"), "server-port.json")
|
||||
}
|
||||
|
||||
function loadSavedPort(): number | null {
|
||||
try {
|
||||
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
|
||||
return Number.isInteger(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the port of the first production launch. A later launch that
|
||||
* found it taken keeps it remembered: the user's data lives under that
|
||||
* origin, and the next launch goes back to it once it is free.
|
||||
*/
|
||||
export function saveServerPort(port: number): void {
|
||||
if (!app.isPackaged || loadSavedPort() !== null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save server port:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||
resolve(false)
|
||||
})
|
||||
server.once("error", () => resolve(false))
|
||||
server.once("listening", () => {
|
||||
server.close()
|
||||
resolve(true)
|
||||
@@ -80,13 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses the port from the last launch, then the legacy
|
||||
* port (61337), then 13370, to preserve localStorage
|
||||
* - In production: uses fixed port (61337) to preserve localStorage
|
||||
* - 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
|
||||
* @returns Promise<number> The available port
|
||||
* @throws Error if no available port found after max attempts
|
||||
*/
|
||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
const isDev = !app.isPackaged
|
||||
@@ -106,30 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, use the port from the last launch first
|
||||
if (!isDev) {
|
||||
const savedPort = loadSavedPort()
|
||||
if (savedPort !== null) {
|
||||
if (await isPortAvailable(savedPort)) {
|
||||
allocatedPort = savedPort
|
||||
return savedPort
|
||||
}
|
||||
console.warn(
|
||||
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// In production, try legacy port first to preserve existing users' localStorage
|
||||
if (!isDev) {
|
||||
const legacyPort = PORT_CONFIG.legacyProduction
|
||||
if (await isPortAvailable(legacyPort)) {
|
||||
allocatedPort = legacyPort
|
||||
return legacyPort
|
||||
}
|
||||
}
|
||||
|
||||
// Try preferred port
|
||||
// Try preferred port first
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
@@ -149,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: let the OS pick an available port
|
||||
console.warn(
|
||||
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
||||
throw new Error(
|
||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,5 +113,5 @@ export function getServerUrl(): string {
|
||||
"No port allocated yet. Call findAvailablePort() first.",
|
||||
)
|
||||
}
|
||||
return `http://127.0.0.1:${allocatedPort}`
|
||||
return `http://localhost:${allocatedPort}`
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { app } from "electron"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import type { ProxyConfig } from "../electron.d"
|
||||
|
||||
export type { ProxyConfig }
|
||||
|
||||
const CONFIG_FILE = "proxy-config.json"
|
||||
|
||||
function getConfigPath(): string {
|
||||
return path.join(app.getPath("userData"), CONFIG_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load proxy configuration from JSON file
|
||||
* Returns null if the user never saved proxy settings (or the file is invalid)
|
||||
*/
|
||||
export function loadProxyConfig(): ProxyConfig | null {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
return data as ProxyConfig
|
||||
}
|
||||
console.error("Ignoring invalid proxy config:", data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load proxy config:", error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save proxy configuration to JSON file
|
||||
*/
|
||||
export function saveProxyConfig(config: ProxyConfig): void {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
fs.renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy config:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply proxy configuration to process.env
|
||||
* Must be called BEFORE starting the Next.js server
|
||||
*/
|
||||
export function applyProxyToEnv(): void {
|
||||
const config = loadProxyConfig()
|
||||
|
||||
// No saved settings: keep proxy vars inherited from the system or .env
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.httpProxy) {
|
||||
process.env.HTTP_PROXY = config.httpProxy
|
||||
process.env.http_proxy = config.httpProxy
|
||||
} else {
|
||||
delete process.env.HTTP_PROXY
|
||||
delete process.env.http_proxy
|
||||
}
|
||||
|
||||
if (config.httpsProxy) {
|
||||
process.env.HTTPS_PROXY = config.httpsProxy
|
||||
process.env.https_proxy = config.httpsProxy
|
||||
} else {
|
||||
delete process.env.HTTPS_PROXY
|
||||
delete process.env.https_proxy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current proxy configuration (from process.env)
|
||||
*/
|
||||
export function getProxyConfig(): ProxyConfig {
|
||||
return {
|
||||
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
|
||||
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@ import { app, BrowserWindow, screen } from "electron"
|
||||
|
||||
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
|
||||
* Note: electron-builder converts icon.png during packaging,
|
||||
@@ -31,7 +28,6 @@ function getIconPath(): string | undefined {
|
||||
* Create the main application window
|
||||
*/
|
||||
export function createWindow(serverUrl: string): BrowserWindow {
|
||||
appUrl = serverUrl
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -60,28 +56,17 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
})
|
||||
|
||||
// Open DevTools in development
|
||||
if (!app.isPackaged) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
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 = null
|
||||
})
|
||||
|
||||
// Handle page title updates
|
||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||
if (
|
||||
title &&
|
||||
!title.includes("localhost") &&
|
||||
!title.includes("127.0.0.1")
|
||||
) {
|
||||
if (title && !title.includes("localhost")) {
|
||||
mainWindow?.setTitle(title)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
@@ -97,36 +82,3 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app server URL the main window loads
|
||||
*/
|
||||
export function getAppUrl(): string | null {
|
||||
return appUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the main window at a new app server URL
|
||||
* (the restarted server can come up on a different port)
|
||||
*/
|
||||
export function setAppUrl(url: string): void {
|
||||
if (url === appUrl) {
|
||||
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,13 +18,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
maximize: () => ipcRenderer.send("window-maximize"),
|
||||
close: () => ipcRenderer.send("window-close"),
|
||||
|
||||
// Proxy settings
|
||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||
ipcRenderer.invoke("set-proxy", config),
|
||||
|
||||
// User locale settings
|
||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||
})
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="siliconflow">SiliconFlow</option>
|
||||
<option value="modelscope">ModelScope</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -213,9 +213,6 @@ async function savePreset() {
|
||||
}
|
||||
})
|
||||
|
||||
// closeModal() clears editingPresetId, so remember it for the toast
|
||||
const isEdit = Boolean(editingPresetId)
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true
|
||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||
@@ -223,7 +220,10 @@ async function savePreset() {
|
||||
await window.settingsAPI.savePreset(preset)
|
||||
await loadPresets()
|
||||
closeModal()
|
||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
||||
showToast(
|
||||
editingPresetId ? "Preset updated" : "Preset created",
|
||||
"success",
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to save preset:", error)
|
||||
showToast("Failed to save preset", "error")
|
||||
@@ -265,6 +265,8 @@ async function applyPreset(id) {
|
||||
|
||||
const result = await window.settingsAPI.applyPreset(id)
|
||||
if (result.success) {
|
||||
currentPresetId = id
|
||||
renderPresets()
|
||||
showToast("Preset applied, server restarting...", "success")
|
||||
} else {
|
||||
showToast(result.error || "Failed to apply preset", "error")
|
||||
@@ -272,9 +274,6 @@ async function applyPreset(id) {
|
||||
} catch (error) {
|
||||
console.error("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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +288,6 @@ function getProviderLabel(provider) {
|
||||
openrouter: "OpenRouter",
|
||||
deepseek: "DeepSeek",
|
||||
siliconflow: "SiliconFlow",
|
||||
modelscope: "ModelScope",
|
||||
ollama: "Ollama",
|
||||
}
|
||||
return labels[provider] || provider
|
||||
|
||||
+6
-103
@@ -1,22 +1,12 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
# 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
|
||||
|
||||
# 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_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||
@@ -35,8 +25,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||
|
||||
# Anthropic (Direct) Configuration
|
||||
# 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_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
||||
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
||||
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
||||
@@ -44,20 +33,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Google Generative AI Configuration
|
||||
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||
# 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_P=0.95 # Optional: Nucleus sampling parameter
|
||||
# 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_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
|
||||
# Configure endpoint using ONE of these methods:
|
||||
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
||||
@@ -69,19 +51,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
||||
# AZURE_REASONING_SUMMARY=detailed
|
||||
|
||||
# Ollama Configuration (Local or Cloud)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
||||
# Ollama (Local) Configuration
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||
|
||||
# OpenRouter Configuration
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
# 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_API_KEY=sk-...
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||
@@ -95,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# SGLANG_API_KEY=your-sglang-api-key
|
||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||
|
||||
# ModelScope Configuration
|
||||
# MODELSCOPE_API_KEY=ms-...
|
||||
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
|
||||
|
||||
# ByteDance Doubao Configuration (via Volcengine)
|
||||
# DOUBAO_API_KEY=your-doubao-api-key
|
||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||
@@ -116,32 +89,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||
# 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)
|
||||
# Controls randomness in AI responses. Lower = more deterministic.
|
||||
# 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.
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||
# 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_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)
|
||||
# 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
|
||||
@@ -157,55 +112,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Enabled by default. Set to "false" to disable.
|
||||
# ENABLE_PDF_INPUT=true
|
||||
# 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,20 +1,8 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import type { DiagramOperation } from "@/components/chat/types"
|
||||
import type {
|
||||
ValidationState,
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
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 {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
@@ -41,31 +29,19 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
||||
|
||||
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 DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: MutableRefObject<number>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
sessionId?: string
|
||||
onValidationStateChange?: (
|
||||
toolCallId: string,
|
||||
state: ValidationState,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,36 +54,11 @@ interface UseDiagramToolHandlersParams {
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
sessionId,
|
||||
onValidationStateChange,
|
||||
}: 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 (
|
||||
{ toolCall }: { toolCall: ToolCall },
|
||||
addToolOutput: AddToolOutputFn,
|
||||
@@ -178,12 +129,11 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||
// create_new_diagram, then load it
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
@@ -210,150 +160,7 @@ ${finalXml}
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||
)
|
||||
}
|
||||
|
||||
// VLM validation after successful display
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
|
||||
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
// Each retry is a new tool call, so count attempts per user turn
|
||||
const attempt = validationRetryCountRef.current + 1
|
||||
|
||||
// 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) {
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current = 0
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"skipped",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - reset retry count
|
||||
validationRetryCountRef.current = 0
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of success (include the image)
|
||||
// Use "success_with_warnings" if valid but has issues
|
||||
const hasWarnings = result.issues.length > 0
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
hasWarnings
|
||||
? "success_with_warnings"
|
||||
: "success",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// PNG capture failed - skip validation
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
error,
|
||||
)
|
||||
updateValidationState(toolCall.toolCallId, "error", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Validation failed",
|
||||
imageData: capturedPngData || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-available",
|
||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
@@ -378,17 +185,12 @@ ${finalXml}
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
// 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,
|
||||
)
|
||||
// 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 {
|
||||
// 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) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
@@ -402,19 +204,26 @@ ${finalXml}
|
||||
}
|
||||
}
|
||||
|
||||
// All or nothing, checked like the MCP server's edit_diagram.
|
||||
// The model sees the first page, so edits target it.
|
||||
const outcome = editDiagram(currentXml, operations, {})
|
||||
if (!outcome.ok) {
|
||||
const reason = outcome.pageError
|
||||
? outcome.errors[0]
|
||||
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
|
||||
restoreOriginal()
|
||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||
const { result: editedXml, errors } = applyDiagramOperations(
|
||||
currentXml,
|
||||
operations,
|
||||
)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${reason}
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
@@ -428,12 +237,35 @@ Please check the cell IDs and retry.`,
|
||||
return
|
||||
}
|
||||
|
||||
onDisplayChart(outcome.xml, true)
|
||||
// loadDiagram validates and returns error if invalid
|
||||
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()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
@@ -443,7 +275,6 @@ Please check the cell IDs and retry.`,
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -468,19 +299,6 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
) => {
|
||||
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
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
@@ -519,10 +337,8 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
|
||||
+26
-152
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
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 {
|
||||
createEmptyConfig,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
flattenModels,
|
||||
type ModelConfig,
|
||||
type MultiModelConfig,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
@@ -101,15 +100,6 @@ function saveConfig(config: MultiModelConfig): void {
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// State
|
||||
config: MultiModelConfig
|
||||
@@ -143,67 +133,14 @@ export interface UseModelConfigReturn {
|
||||
export function useModelConfig(): UseModelConfigReturn {
|
||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
|
||||
const [serverLoaded, setServerLoaded] = useState(false)
|
||||
|
||||
// Load client config on mount
|
||||
// Load config on mount
|
||||
useEffect(() => {
|
||||
const loaded = loadConfig()
|
||||
setConfig(loaded)
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
fetch(getApiEndpoint("/api/server-models"))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to load server models:",
|
||||
res.status,
|
||||
res.statusText,
|
||||
)
|
||||
throw new Error(`Request failed with status ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const raw: FlattenedServerModel[] = data?.models || []
|
||||
setServerModels(raw)
|
||||
setServerLoaded(true)
|
||||
|
||||
// Auto-select 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
|
||||
const fallback = 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)
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
@@ -212,33 +149,9 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
}, [config, isLoaded])
|
||||
|
||||
// Derived state
|
||||
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 models = flattenModels(config)
|
||||
const selectedModel = config.selectedModelId
|
||||
? models.find((m) => m.id === config.selectedModelId)
|
||||
? findModelById(config, config.selectedModelId)
|
||||
: undefined
|
||||
|
||||
// Actions
|
||||
@@ -280,31 +193,24 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
const deleteProvider = useCallback((providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
|
||||
// Fall back to the default server model if the selected model
|
||||
// belongs to the deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId &&
|
||||
modelIds.includes(prev.selectedModelId)
|
||||
? defaultServerModelId(serverModels)
|
||||
: prev.selectedModelId
|
||||
// Clear selected model if it belongs to deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||
? undefined
|
||||
: prev.selectedModelId
|
||||
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter(
|
||||
(p) => p.id !== providerId,
|
||||
),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
},
|
||||
[serverModels],
|
||||
)
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addModel = useCallback(
|
||||
(providerId: string, modelId: string): ModelConfig => {
|
||||
@@ -361,15 +267,14 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
}
|
||||
: p,
|
||||
),
|
||||
// Fall back to the default server model if the selected model
|
||||
// was deleted
|
||||
// Clear selected model if it was deleted
|
||||
selectedModelId:
|
||||
prev.selectedModelId === modelConfigId
|
||||
? defaultServerModelId(serverModels)
|
||||
? undefined
|
||||
: prev.selectedModelId,
|
||||
}))
|
||||
},
|
||||
[serverModels],
|
||||
[],
|
||||
)
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
@@ -378,7 +283,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoaded: isLoaded && serverLoaded,
|
||||
isLoaded,
|
||||
models,
|
||||
selectedModel,
|
||||
selectedModelId: config.selectedModelId,
|
||||
@@ -410,10 +315,6 @@ export function getSelectedAIConfig(): {
|
||||
awsSecretAccessKey: string
|
||||
awsRegion: string
|
||||
awsSessionToken: string
|
||||
// Selected model ID (for server model lookup)
|
||||
selectedModelId: string
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: string
|
||||
} {
|
||||
const empty = {
|
||||
accessCode: "",
|
||||
@@ -425,8 +326,6 @@ export function getSelectedAIConfig(): {
|
||||
awsSecretAccessKey: "",
|
||||
awsRegion: "",
|
||||
awsSessionToken: "",
|
||||
selectedModelId: "",
|
||||
vertexApiKey: "",
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return empty
|
||||
@@ -449,8 +348,6 @@ export function getSelectedAIConfig(): {
|
||||
awsSecretAccessKey: "",
|
||||
awsRegion: "",
|
||||
awsSessionToken: "",
|
||||
selectedModelId: "",
|
||||
vertexApiKey: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,32 +358,12 @@ export function getSelectedAIConfig(): {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
|
||||
// No selected model = use server default
|
||||
if (!config.selectedModelId) {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
// 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
|
||||
// Find selected model
|
||||
const model = findModelById(config, config.selectedModelId)
|
||||
if (!model) {
|
||||
return { ...empty, accessCode }
|
||||
@@ -503,8 +380,5 @@ export function getSelectedAIConfig(): {
|
||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||
awsRegion: model.awsRegion || "",
|
||||
awsSessionToken: model.awsSessionToken || "",
|
||||
selectedModelId: config.selectedModelId || "",
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: model.vertexApiKey || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type ChatSession,
|
||||
createEmptySession,
|
||||
deleteSession as deleteSessionFromDB,
|
||||
enforceSessionLimit,
|
||||
extractTitle,
|
||||
getAllSessionMetadata,
|
||||
getSession,
|
||||
isIndexedDBAvailable,
|
||||
migrateFromLocalStorage,
|
||||
type SessionMetadata,
|
||||
type StoredMessage,
|
||||
saveSession,
|
||||
} from "@/lib/session-storage"
|
||||
|
||||
export interface SessionData {
|
||||
messages: StoredMessage[]
|
||||
xmlSnapshots: [number, string][]
|
||||
diagramXml: string
|
||||
thumbnailDataUrl?: string
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
}
|
||||
|
||||
export interface UseSessionManagerReturn {
|
||||
// State
|
||||
sessions: SessionMetadata[]
|
||||
currentSessionId: string | null
|
||||
currentSession: ChatSession | null
|
||||
isLoading: boolean
|
||||
isAvailable: boolean
|
||||
|
||||
// Actions
|
||||
switchSession: (id: string) => Promise<SessionData | null>
|
||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||
saveCurrentSession: (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
) => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
}
|
||||
|
||||
// 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 {
|
||||
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
||||
initialSessionId?: string | null
|
||||
}
|
||||
|
||||
export function useSessionManager(
|
||||
options: UseSessionManagerOptions = {},
|
||||
): UseSessionManagerReturn {
|
||||
const { initialSessionId } = options
|
||||
const dict = useDictionary()
|
||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
||||
null,
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isAvailable, setIsAvailable] = useState(false)
|
||||
|
||||
const isInitializedRef = useRef(false)
|
||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||
const urlChangeSequenceRef = useRef(0)
|
||||
|
||||
// Load sessions list
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
const metadata = await getAllSessionMetadata()
|
||||
setSessions(metadata)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh sessions:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current) return
|
||||
isInitializedRef.current = true
|
||||
|
||||
async function init() {
|
||||
setIsLoading(true)
|
||||
|
||||
if (!isIndexedDBAvailable()) {
|
||||
setIsAvailable(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsAvailable(true)
|
||||
|
||||
try {
|
||||
// Run migration first (one-time conversion from localStorage)
|
||||
await migrateFromLocalStorage()
|
||||
|
||||
// Load sessions list
|
||||
const metadata = await getAllSessionMetadata()
|
||||
setSessions(metadata)
|
||||
|
||||
// Only load a session if initialSessionId is provided (from URL param)
|
||||
if (initialSessionId) {
|
||||
const session = await getSession(initialSessionId)
|
||||
if (session) {
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
}
|
||||
// If session not found, stay in blank state (URL has invalid session ID)
|
||||
}
|
||||
// If no initialSessionId, start with blank state (no auto-restore)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize session manager:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
}, [initialSessionId])
|
||||
|
||||
// Handle URL session ID changes after initialization
|
||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||
// when clearCurrentSession() is called before URL updates
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return // Wait for initial load
|
||||
if (!isAvailable) return
|
||||
|
||||
// Increment sequence to invalidate any pending async operations
|
||||
urlChangeSequenceRef.current++
|
||||
const currentSequence = urlChangeSequenceRef.current
|
||||
|
||||
async function handleSessionIdChange() {
|
||||
if (initialSessionId) {
|
||||
// URL has session ID - load it
|
||||
const session = await getSession(initialSessionId)
|
||||
|
||||
// Check if this request is still the latest (sequence guard)
|
||||
// If not, a newer URL change happened while we were loading
|
||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (session) {
|
||||
// Only update if the session is different from current
|
||||
setCurrentSessionId((current) => {
|
||||
if (current !== session.id) {
|
||||
setCurrentSession(session)
|
||||
return session.id
|
||||
}
|
||||
return current
|
||||
})
|
||||
}
|
||||
}
|
||||
// Removed: else clause that clears session
|
||||
// Clearing is now handled explicitly by clearCurrentSession()
|
||||
// This prevents race conditions when URL update is async
|
||||
}
|
||||
|
||||
handleSessionIdChange()
|
||||
}, [initialSessionId, isAvailable])
|
||||
|
||||
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
||||
const lastFocusRefreshRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastFocusRefreshRef.current = now
|
||||
refreshSessions()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => window.removeEventListener("focus", handleFocus)
|
||||
}, [refreshSessions])
|
||||
|
||||
// Switch to a different session
|
||||
const switchSession = useCallback(
|
||||
async (id: string): Promise<SessionData | null> => {
|
||||
if (id === currentSessionId) return null
|
||||
|
||||
// Save current session first if it has messages
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
await saveSession(currentSession)
|
||||
}
|
||||
|
||||
// Load the target session
|
||||
const session = await getSession(id)
|
||||
if (!session) {
|
||||
console.error("Session not found:", id)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update state
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
|
||||
return {
|
||||
messages: session.messages,
|
||||
xmlSnapshots: session.xmlSnapshots,
|
||||
diagramXml: session.diagramXml,
|
||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||
diagramHistory: session.diagramHistory,
|
||||
}
|
||||
},
|
||||
[currentSessionId, currentSession],
|
||||
)
|
||||
|
||||
// Delete a session
|
||||
const deleteSession = useCallback(
|
||||
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
||||
const wasCurrentSession = id === currentSessionId
|
||||
await deleteSessionFromDB(id)
|
||||
|
||||
// If deleting current session, clear state (caller will show new empty session)
|
||||
if (wasCurrentSession) {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}
|
||||
|
||||
await refreshSessions()
|
||||
|
||||
return { wasCurrentSession }
|
||||
},
|
||||
[currentSessionId, refreshSessions],
|
||||
)
|
||||
|
||||
// Save current session data (debounced externally by caller)
|
||||
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||
const saveCurrentSession = useCallback(
|
||||
async (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
): Promise<void> => {
|
||||
// If forSessionId is provided, verify it matches current session
|
||||
// This prevents stale debounced saves from overwriting a newly switched session
|
||||
if (
|
||||
forSessionId !== undefined &&
|
||||
forSessionId !== currentSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Nothing can be stored without IndexedDB
|
||||
if (!isIndexedDBAvailable()) return
|
||||
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
const newSession: ChatSession = {
|
||||
...createEmptySession(),
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
}
|
||||
// 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
|
||||
}
|
||||
await enforceSessionLimit()
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
await refreshSessions()
|
||||
return
|
||||
}
|
||||
|
||||
// Update existing session
|
||||
const updatedSession: ChatSession = {
|
||||
...currentSession,
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
currentSession.title === "New Chat" &&
|
||||
data.messages.length > 0
|
||||
? extractTitle(data.messages)
|
||||
: currentSession.title,
|
||||
}
|
||||
|
||||
if (!(await saveSession(updatedSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
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, dict],
|
||||
)
|
||||
|
||||
// Clear current session state (for starting fresh without loading another session)
|
||||
const clearCurrentSession = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
currentSession,
|
||||
isLoading,
|
||||
isAvailable,
|
||||
switchSession,
|
||||
deleteSession,
|
||||
saveCurrentSession,
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"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],
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate with fallback - returns default valid result on error.
|
||||
* Use this to avoid blocking the user on validation failures.
|
||||
*/
|
||||
const validateWithFallback = useCallback(
|
||||
async (
|
||||
imageData: string,
|
||||
sessionId?: string,
|
||||
): Promise<ValidationResult> => {
|
||||
try {
|
||||
return await validate(imageData, sessionId)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useValidateDiagram] Validation failed, using fallback:",
|
||||
error,
|
||||
)
|
||||
return DEFAULT_VALID_RESULT
|
||||
}
|
||||
},
|
||||
[validate],
|
||||
)
|
||||
|
||||
return {
|
||||
// Validation functions
|
||||
validate,
|
||||
validateWithFallback,
|
||||
stop,
|
||||
|
||||
// State
|
||||
isValidating: isLoading,
|
||||
partialResult: object as ValidationResult | undefined,
|
||||
error,
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -1,17 +1,7 @@
|
||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export function register() {
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn(
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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 },
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user