mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 12:39:52 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7525253499 | ||
|
|
384221c498 | ||
|
|
7877b39fa1 |
@@ -47,10 +47,6 @@ To run tests with UI mode:
|
|||||||
npx playwright test --ui
|
npx playwright test --ui
|
||||||
```
|
```
|
||||||
|
|
||||||
## Before You Start
|
|
||||||
|
|
||||||
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
|
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
@@ -61,21 +57,6 @@ For **significant changes** (new features, architecture changes, large refactors
|
|||||||
|
|
||||||
CI will run the full test suite on your PR.
|
CI will run the full test suite on your PR.
|
||||||
|
|
||||||
## Using AI Tools
|
|
||||||
|
|
||||||
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
|
|
||||||
|
|
||||||
1. **Review the code** — understand what was generated, don't just commit blindly
|
|
||||||
2. **Write a PR description** — explain what changed and why
|
|
||||||
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
|
|
||||||
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
|
|
||||||
|
|
||||||
## Code Review
|
|
||||||
|
|
||||||
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
|
||||||
- **Valid suggestions**: Please address them in your code.
|
|
||||||
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
|
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
||||||
|
|||||||
@@ -33,11 +33,6 @@
|
|||||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
"groupName": "Core framework packages",
|
"groupName": "Core framework packages",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchPackageNames": ["@biomejs/biome"],
|
|
||||||
"groupName": "Biome",
|
|
||||||
"automerge": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Run Biome format
|
- name: Run Biome format
|
||||||
# Pin to the version in package.json so CI matches local/pre-commit
|
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
|
||||||
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
|
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: npm audit --audit-level=high --omit=dev
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -91,3 +89,4 @@ jobs:
|
|||||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Download draw.io static files for offline use
|
- name: Download draw.io static files for offline use
|
||||||
run: |
|
run: |
|
||||||
rm -rf public/drawio
|
rm -rf public/drawio
|
||||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
mkdir -p public/drawio
|
mkdir -p public/drawio
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
rm -rf public/drawio/WEB-INF
|
rm -rf public/drawio/WEB-INF
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
rm -rf public/drawio
|
rm -rf public/drawio
|
||||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
mkdir -p public/drawio
|
mkdir -p public/drawio
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
rm -rf public/drawio/WEB-INF
|
rm -rf public/drawio/WEB-INF
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -28,20 +28,6 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
# The MCP server package ships its own vitest because its DOM polyfill
|
|
||||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
|
||||||
# for the Next.js app. Install + run its tests separately so CI catches
|
|
||||||
# multi-page mxfile regressions.
|
|
||||||
- name: Install MCP server dependencies
|
|
||||||
run: npm --prefix packages/mcp-server ci
|
|
||||||
|
|
||||||
- name: Run MCP server unit tests
|
|
||||||
run: npm --prefix packages/mcp-server test
|
|
||||||
|
|
||||||
# Tests run from src/, so check the built npm package separately
|
|
||||||
- name: Build MCP server and check package contents
|
|
||||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -72,10 +72,3 @@ CLAUDE.md
|
|||||||
.edgeone
|
.edgeone
|
||||||
opencode.json
|
opencode.json
|
||||||
ai-models.json
|
ai-models.json
|
||||||
|
|
||||||
# local backups
|
|
||||||
*.bak
|
|
||||||
.gstack/
|
|
||||||
|
|
||||||
# admin panel settings (contains secrets)
|
|
||||||
data/
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Stage 2: Build application
|
# Stage 2: Build application
|
||||||
@@ -35,11 +34,6 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
|||||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||||
|
|
||||||
# Control sponsorship and self-hosting messaging in quota notifications.
|
|
||||||
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
|
|
||||||
ARG NEXT_PUBLIC_SELFHOSTED=""
|
|
||||||
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
|
|
||||||
|
|
||||||
# Build Next.js application (standalone mode)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -61,9 +55,6 @@ COPY --from=builder /app/public ./public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Writable dir for admin panel settings (data/settings.json)
|
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -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.
|
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!
|
> 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!
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
|
||||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
|
||||||
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
@@ -42,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Table of Contents](#table-of-contents)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [MCP Server](#mcp-server)
|
- [MCP Server (Preview)](#mcp-server-preview)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Try it Online](#try-it-online)
|
- [Try it Online](#try-it-online)
|
||||||
@@ -54,8 +43,6 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
- [Deploy on Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
|
||||||
- [Admin Panel](#admin-panel)
|
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -76,24 +63,24 @@ Here are some example prompts and their generated diagrams:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG Technique Diagram</strong><br />
|
<strong>GCP architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Authentication using React and AWS</strong><br />
|
<strong>AWS architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Open Innovation</strong><br />
|
<strong>Azure architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch</strong><br />
|
<strong>Cat sketch prompt</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||||
</td>
|
</td>
|
||||||
@@ -112,7 +99,9 @@ Here are some example prompts and their generated diagrams:
|
|||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server (Preview)
|
||||||
|
|
||||||
|
> **Preview Feature**: This feature is experimental and may not be stable.
|
||||||
|
|
||||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||||
|
|
||||||
@@ -138,13 +127,6 @@ Then ask Claude to create diagrams:
|
|||||||
|
|
||||||
The diagram appears in your browser in real-time!
|
The diagram appears in your browser in real-time!
|
||||||
|
|
||||||
The MCP server includes most of the web app's drawing features:
|
|
||||||
|
|
||||||
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
|
||||||
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
|
||||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
|
||||||
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
|
||||||
|
|
||||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -220,7 +202,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
|
|
||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
- [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)
|
- AWS Bedrock (default)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -229,13 +211,11 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
|
||||||
|
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||||
@@ -244,13 +224,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
### 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`.
|
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.
|
||||||
|
|
||||||
### Admin Panel
|
|
||||||
|
|
||||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
|
||||||
|
|
||||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
@@ -270,9 +244,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
|
|
||||||
## Support & Contact
|
## Support & Contact
|
||||||
|
|
||||||
**Special thanks to [ByteDance Doubao](https://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 [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||||
|
|
||||||
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
|
||||||
|
|
||||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "关于 - Next AI Draw.io",
|
title: "关于 - Next AI Draw.io",
|
||||||
@@ -78,7 +78,7 @@ export default function AboutCN() {
|
|||||||
<p>
|
<p>
|
||||||
好消息!感谢{" "}
|
好消息!感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -87,7 +87,7 @@ export default function AboutCN() {
|
|||||||
</a>
|
</a>
|
||||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
K2-thinking
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -97,23 +97,6 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite Poster */}
|
|
||||||
<div className="text-center mb-5">
|
|
||||||
<a
|
|
||||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/volcengine-invite.png"
|
|
||||||
alt="火山引擎方舟 Coding Plan"
|
|
||||||
width={300}
|
|
||||||
height={400}
|
|
||||||
className="mx-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -175,106 +158,92 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ResNet50模型架构动画
|
动画Transformer连接器
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>提示词:</strong> 给我一个带有
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>动画连接器</strong>的Transformer架构图。
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="带动画连接器的Transformer架构"
|
||||||
alt="ResNet50模型架构图"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG技术图
|
GCP架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>提示词:</strong> 使用
|
||||||
architecture diagram for{" "}
|
<strong>GCP图标</strong>
|
||||||
<strong>chat application</strong>. Use
|
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP架构图"
|
||||||
alt="RAG架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
React和AWS认证流程
|
AWS架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>提示词:</strong> 使用
|
||||||
authentication process using React with{" "}
|
<strong>AWS图标</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS架构图"
|
||||||
alt="认证架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
敏捷Scrum流程
|
Azure架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>提示词:</strong> 使用
|
||||||
scrum workflow diagram for software
|
<strong>Azure图标</strong>
|
||||||
development team.
|
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure架构图"
|
||||||
alt="敏捷Scrum流程图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
开放式创新
|
猫咪素描
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>提示词:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
给我画一只可爱的猫。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫咪绘图"
|
||||||
alt="开放式创新图"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +277,7 @@ export default function AboutCN() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -343,7 +312,7 @@ export default function AboutCN() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
特别感谢{" "}
|
特别感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "概要 - Next AI Draw.io",
|
title: "概要 - Next AI Draw.io",
|
||||||
@@ -86,7 +86,7 @@ export default function AboutJA() {
|
|||||||
<p>
|
<p>
|
||||||
朗報です!
|
朗報です!
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -95,7 +95,7 @@ export default function AboutJA() {
|
|||||||
</a>
|
</a>
|
||||||
様のご支援により、デモサイトでは強力な{" "}
|
様のご支援により、デモサイトでは強力な{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
K2-thinking
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -168,106 +168,93 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ResNet50モデルアーキテクチャアニメーション
|
アニメーションTransformerコネクタ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>プロンプト:</strong>{" "}
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>アニメーションコネクタ</strong>
|
||||||
of the ResNet50 model.
|
付きのTransformerアーキテクチャ図を作成してください。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||||
alt="ResNet50モデルアーキテクチャ図"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG技術ダイアグラム
|
GCPアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>プロンプト:</strong>{" "}
|
||||||
architecture diagram for{" "}
|
<strong>GCPアイコン</strong>
|
||||||
<strong>chat application</strong>. Use
|
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCPアーキテクチャ図"
|
||||||
alt="RAGアーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ReactとAWSによる認証
|
AWSアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>プロンプト:</strong>{" "}
|
||||||
authentication process using React with{" "}
|
<strong>AWSアイコン</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWSアーキテクチャ図"
|
||||||
alt="認証アーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
アジャイルスクラムプロセス
|
Azureアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>プロンプト:</strong>{" "}
|
||||||
scrum workflow diagram for software
|
<strong>Azureアイコン</strong>
|
||||||
development team.
|
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azureアーキテクチャ図"
|
||||||
alt="アジャイルスクラム図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
オープンイノベーション
|
猫のスケッチ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>プロンプト:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
かわいい猫を描いてください。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫の絵"
|
||||||
alt="オープンイノベーション図"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +292,7 @@ export default function AboutJA() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -340,7 +327,7 @@ export default function AboutJA() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
+63
-73
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { FaGithub } from "react-icons/fa"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "About - Next AI Draw.io",
|
title: "About - Next AI Draw.io",
|
||||||
@@ -87,7 +87,7 @@ export default function About() {
|
|||||||
Great news! Thanks to the generous
|
Great news! Thanks to the generous
|
||||||
sponsorship from{" "}
|
sponsorship from{" "}
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -96,7 +96,7 @@ export default function About() {
|
|||||||
</a>
|
</a>
|
||||||
, the demo site now uses the powerful{" "}
|
, the demo site now uses the powerful{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
glm-4.7
|
K2-thinking
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
model for better diagram generation! Sign up
|
model for better diagram generation! Sign up
|
||||||
via the link to get{" "}
|
via the link to get{" "}
|
||||||
@@ -182,106 +182,96 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated ResNet50 Model Architecture
|
Animated Transformer Connectors
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>animated connector</strong> diagram of
|
||||||
of the ResNet50 model.
|
transformer's architecture.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="Transformer Architecture with Animated Connectors"
|
||||||
alt="Architecture diagram for ResNet50 model"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
RAG Technique Diagram
|
GCP Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<strong>Prompt:</strong> Generate a GCP
|
||||||
architecture diagram for{" "}
|
architecture diagram with{" "}
|
||||||
<strong>chat application</strong>. Use
|
<strong>GCP icons</strong>. Users connect to
|
||||||
connected diagram for data ingestion
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP Architecture Diagram"
|
||||||
alt="RAG Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Authentication using React and AWS
|
AWS Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate
|
<strong>Prompt:</strong> Generate an AWS
|
||||||
authentication process using React with{" "}
|
architecture diagram with{" "}
|
||||||
<strong>AWS</strong>. Use Serverless
|
<strong>AWS icons</strong>. Users connect to
|
||||||
architecture.
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS Architecture Diagram"
|
||||||
alt="Authentication Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Agile Scrum Process
|
Azure Architecture Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>Prompt:</strong> Generate an Azure
|
||||||
scrum workflow diagram for software
|
architecture diagram with{" "}
|
||||||
development team.
|
<strong>Azure icons</strong>. Users connect
|
||||||
|
to a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure Architecture Diagram"
|
||||||
alt="Agile Scrum Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Open Innovation
|
Cat Sketch
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>Prompt:</strong> Draw a cute cat for
|
||||||
visualization of Henry Chesbrough's
|
me.
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="Cat Drawing"
|
||||||
alt="Open Innovation Diagram"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +311,7 @@ export default function About() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -358,7 +348,7 @@ export default function About() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
Special thanks to{" "}
|
Special thanks to{" "}
|
||||||
<a
|
<a
|
||||||
href="https://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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
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,627 +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 { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import {
|
|
||||||
FIXED_CRED_PROVIDERS,
|
|
||||||
generateId,
|
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderName,
|
|
||||||
SUGGESTED_MODELS,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
type AdminProvider,
|
|
||||||
adminFetch,
|
|
||||||
type EnvProvider,
|
|
||||||
} from "./admin-shared"
|
|
||||||
import { SecretInput } from "./setting-field"
|
|
||||||
|
|
||||||
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
|
||||||
|
|
||||||
function ProviderDetail({
|
|
||||||
provider,
|
|
||||||
disabled,
|
|
||||||
password,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
provider: AdminProvider
|
|
||||||
disabled: boolean
|
|
||||||
password: string
|
|
||||||
onUpdate: (patch: Partial<AdminProvider>) => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [modelInput, setModelInput] = useState("")
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const [testing, setTesting] = useState<string | null>(null)
|
|
||||||
const [testResults, setTestResults] = useState<
|
|
||||||
Record<string, { ok: boolean; message: string }>
|
|
||||||
>({})
|
|
||||||
|
|
||||||
const info = PROVIDER_INFO[provider.provider]
|
|
||||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
|
||||||
(m) => !provider.models.includes(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
const addModel = (modelId: string) => {
|
|
||||||
const trimmed = modelId.trim()
|
|
||||||
if (!trimmed || provider.models.includes(trimmed)) return
|
|
||||||
onUpdate({ models: [...provider.models, trimmed] })
|
|
||||||
setModelInput("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const testModel = async (modelId: string) => {
|
|
||||||
setTesting(modelId)
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("/api/admin/test-model", password, {
|
|
||||||
method: "POST",
|
|
||||||
// EdgeOne's function also checks the access code
|
|
||||||
headers: {
|
|
||||||
"x-access-code":
|
|
||||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ provider, modelId }),
|
|
||||||
})
|
|
||||||
setTestResults((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[modelId]: data.valid
|
|
||||||
? {
|
|
||||||
ok: true,
|
|
||||||
message: formatMessage(dict.admin.testOk, {
|
|
||||||
ms: data.responseTime,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
ok: false,
|
|
||||||
message: data.error || dict.admin.testFailed,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
setTestResults((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[modelId]: {
|
|
||||||
ok: false,
|
|
||||||
message:
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: dict.admin.testFailed,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
} finally {
|
|
||||||
setTesting(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<ProviderLogo
|
|
||||||
provider={provider.provider}
|
|
||||||
className="size-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold">{info.label}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{provider.models.length === 0
|
|
||||||
? dict.admin.noModelsConfigured
|
|
||||||
: formatMessage(
|
|
||||||
provider.models.length === 1
|
|
||||||
? dict.admin.modelCount
|
|
||||||
: dict.admin.modelCountPlural,
|
|
||||||
{ count: provider.models.length },
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
provider.isDefault &&
|
|
||||||
"fill-amber-400 text-amber-400",
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.admin.default}
|
|
||||||
<Switch
|
|
||||||
checked={!!provider.isDefault}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={dict.admin.setAsDefault}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onUpdate({ isDefault: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
|
||||||
{dict.admin.delete}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
|
||||||
<ProviderCredentialsFields
|
|
||||||
provider={provider.provider}
|
|
||||||
name={provider.name}
|
|
||||||
baseUrl={provider.baseUrl}
|
|
||||||
awsRegion={provider.awsRegion}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
|
||||||
renderSecret={({ field, id }) => (
|
|
||||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
|
||||||
// associated; only one ProviderDetail is mounted at a time.
|
|
||||||
<SecretInput
|
|
||||||
id={id}
|
|
||||||
keepOnEmpty
|
|
||||||
value={provider[field]}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(v) => onUpdate({ [field]: v })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Models */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
{dict.admin.models}
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
value={modelInput}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={dict.admin.modelIdPlaceholder}
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-8 w-48 font-mono text-xs"
|
|
||||||
onChange={(e) => setModelInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") addModel(modelInput)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={disabled || !modelInput.trim()}
|
|
||||||
aria-label={dict.admin.addModel}
|
|
||||||
onClick={() => addModel(modelInput)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<Select
|
|
||||||
value=""
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => addModel(v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-28 text-xs">
|
|
||||||
{dict.admin.suggested}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-72">
|
|
||||||
{suggestions.map((m) => (
|
|
||||||
<SelectItem
|
|
||||||
key={m}
|
|
||||||
value={m}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
{provider.models.length === 0 ? (
|
|
||||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
|
||||||
{dict.admin.addProviderToOfferModels}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y">
|
|
||||||
{provider.models.map((modelId, index) => {
|
|
||||||
const result = testResults[modelId]
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={modelId}
|
|
||||||
className="flex items-center gap-2 px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
||||||
{modelId}
|
|
||||||
{provider.isDefault &&
|
|
||||||
index === 0 && (
|
|
||||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.defaultModel
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{result && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 text-xs",
|
|
||||||
result.ok
|
|
||||||
? "text-green-600 dark:text-green-400"
|
|
||||||
: "text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{result.ok ? (
|
|
||||||
<Check
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AlertCircle
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="max-w-48 truncate">
|
|
||||||
{result.message}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
disabled={
|
|
||||||
disabled || testing !== null
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void testModel(modelId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{testing === modelId ? (
|
|
||||||
<Loader2
|
|
||||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Zap
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="ml-1">
|
|
||||||
{dict.admin.test}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={formatMessage(
|
|
||||||
dict.admin.removeModel,
|
|
||||||
{ model: modelId },
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
onUpdate({
|
|
||||||
models: provider.models.filter(
|
|
||||||
(m) => m !== modelId,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
|
||||||
name: provider.name || info.label,
|
|
||||||
})}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{dict.admin.deleteProviderDesc}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{dict.admin.cancel}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteOpen(false)
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.admin.delete}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelsSection({
|
|
||||||
providers,
|
|
||||||
envProviders,
|
|
||||||
envHasDefaultModel,
|
|
||||||
disabled,
|
|
||||||
password,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
providers: AdminProvider[]
|
|
||||||
envProviders: EnvProvider[]
|
|
||||||
envHasDefaultModel: boolean
|
|
||||||
disabled: boolean
|
|
||||||
password: string
|
|
||||||
onChange: (providers: AdminProvider[]) => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
|
||||||
providers[0]?.id ?? null,
|
|
||||||
)
|
|
||||||
const selected = providers.find((p) => p.id === selectedId)
|
|
||||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
|
||||||
|
|
||||||
const addProvider = (provider: ProviderName) => {
|
|
||||||
const newProvider: AdminProvider = {
|
|
||||||
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
|
||||||
id: generateId(),
|
|
||||||
provider,
|
|
||||||
models: [],
|
|
||||||
// Only the very first provider becomes the default, and only when
|
|
||||||
// the env config has no default that it would replace on save
|
|
||||||
isDefault:
|
|
||||||
providers.length === 0 &&
|
|
||||||
!envProviders.some((p) => p.isDefault) &&
|
|
||||||
!envHasDefaultModel,
|
|
||||||
}
|
|
||||||
onChange([...providers, newProvider])
|
|
||||||
setSelectedId(newProvider.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
|
||||||
onChange(
|
|
||||||
providers.map((p) => {
|
|
||||||
if (p.id !== id) {
|
|
||||||
// Only one default at a time
|
|
||||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
|
||||||
}
|
|
||||||
return { ...p, ...patch }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProvider = (id: string) => {
|
|
||||||
const next = providers.filter((p) => p.id !== id)
|
|
||||||
onChange(next)
|
|
||||||
setSelectedId(next[0]?.id ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
|
||||||
{/* Provider list */}
|
|
||||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
|
||||||
<div className="flex-1 space-y-1 p-2">
|
|
||||||
{providers.length === 0 && envProviders.length === 0 && (
|
|
||||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
|
||||||
{dict.admin.addProviderHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{envProviders.map((p) => (
|
|
||||||
<button
|
|
||||||
key={`env:${p.name}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
selectedId === `env:${p.name}` &&
|
|
||||||
"bg-muted font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProviderLogo provider={p.provider} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
|
||||||
{dict.admin.sourceEnv}
|
|
||||||
</span>
|
|
||||||
{p.isDefault && (
|
|
||||||
<Star
|
|
||||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
|
||||||
aria-label={dict.admin.defaultProvider}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{providers.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(p.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
selectedId === p.id && "bg-muted font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProviderLogo provider={p.provider} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{p.name || PROVIDER_INFO[p.provider].label}
|
|
||||||
</span>
|
|
||||||
{p.isDefault && (
|
|
||||||
<Star
|
|
||||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
|
||||||
aria-label={dict.admin.defaultProvider}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-t p-2">
|
|
||||||
{/* Always empty so picking the same type again still fires */}
|
|
||||||
<Select
|
|
||||||
value=""
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<Plus
|
|
||||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.modelConfig.addProvider}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-72">
|
|
||||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
|
||||||
(p) => {
|
|
||||||
// Global-credential providers already in
|
|
||||||
// the env config can't be added here —
|
|
||||||
// panel credentials would override theirs
|
|
||||||
const envBlocked =
|
|
||||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
|
||||||
envProviders.some(
|
|
||||||
(e) => e.provider === p,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<SelectItem
|
|
||||||
key={p}
|
|
||||||
value={p}
|
|
||||||
disabled={envBlocked}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderLogo provider={p} />
|
|
||||||
{PROVIDER_INFO[p].label}
|
|
||||||
{envBlocked && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.managedViaEnv
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail */}
|
|
||||||
<div className="min-w-0 flex-1 p-4">
|
|
||||||
{selected ? (
|
|
||||||
<ProviderDetail
|
|
||||||
key={selected.id}
|
|
||||||
provider={selected}
|
|
||||||
disabled={disabled}
|
|
||||||
password={password}
|
|
||||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
|
||||||
onDelete={() => deleteProvider(selected.id)}
|
|
||||||
/>
|
|
||||||
) : selectedEnv ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<ProviderLogo
|
|
||||||
provider={selectedEnv.provider}
|
|
||||||
className="size-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold">
|
|
||||||
{selectedEnv.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.admin.envReadOnly}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
<ul className="divide-y">
|
|
||||||
{selectedEnv.models.map((modelId, index) => (
|
|
||||||
<li
|
|
||||||
key={modelId}
|
|
||||||
className="flex items-center gap-2 px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
||||||
{modelId}
|
|
||||||
{selectedEnv.isDefault &&
|
|
||||||
index === 0 && (
|
|
||||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.defaultModel
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
||||||
{dict.admin.selectProviderHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 }>
|
params: Promise<{ lang: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { lang: rawLang } = await params
|
const { lang: rawLang } = await params
|
||||||
const lang = (
|
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
||||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
|
||||||
) as Locale
|
|
||||||
|
|
||||||
// Default to English metadata
|
// Default to English metadata
|
||||||
const titles: Record<Locale, string> = {
|
const titles: Record<Locale, string> = {
|
||||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptions: Record<Locale, string> = {
|
const descriptions: Record<Locale, string> = {
|
||||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||||
"zh-Hant":
|
|
||||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,14 +80,7 @@ export async function generateMetadata({
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
locale:
|
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
||||||
lang === "zh"
|
|
||||||
? "zh_CN"
|
|
||||||
: lang === "zh-Hant"
|
|
||||||
? "zh_HK"
|
|
||||||
: lang === "ja"
|
|
||||||
? "ja_JP"
|
|
||||||
: "en_US",
|
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
@@ -127,7 +115,6 @@ export async function generateMetadata({
|
|||||||
en: "/en",
|
en: "/en",
|
||||||
zh: "/zh",
|
zh: "/zh",
|
||||||
ja: "/ja",
|
ja: "/ja",
|
||||||
"zh-Hant": "/zh-Hant",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-30
@@ -10,8 +10,8 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
import { isIndexedDBUsable } from "@/lib/session-storage"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const {
|
||||||
@@ -27,16 +27,18 @@ export default function Home() {
|
|||||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [isElectron, setIsElectron] = useState(false)
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
|
const [canPersist, setCanPersist] = useState(false)
|
||||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
)
|
)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,15 +49,13 @@ export default function Home() {
|
|||||||
const currentLocale = pathParts[0]
|
const currentLocale = pathParts[0]
|
||||||
if (currentLocale !== savedLocale) {
|
if (currentLocale !== savedLocale) {
|
||||||
pathParts[0] = savedLocale
|
pathParts[0] = savedLocale
|
||||||
// Keep the query (e.g. ?session=) and hash
|
router.replace(`/${pathParts.join("/")}`)
|
||||||
const { search, hash } = window.location
|
|
||||||
router.replace(`/${pathParts.join("/")}${search}${hash}`)
|
|
||||||
return // Wait for redirect
|
return // Wait for redirect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (isDrawioTheme(savedUi)) {
|
if (savedUi === "min" || savedUi === "sketch") {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,10 @@ export default function Home() {
|
|||||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const usable = await isIndexedDBUsable()
|
||||||
|
setCanPersist(usable)
|
||||||
|
})()
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -91,6 +95,17 @@ export default function Home() {
|
|||||||
onDrawioLoad()
|
onDrawioLoad()
|
||||||
}, [onDrawioLoad])
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
|
const handleDrawioAutoSave = useCallback(
|
||||||
|
(data: { xml?: string }) => {
|
||||||
|
handleDiagramAutoSave(data)
|
||||||
|
// Only suppress modified state when persistence is available
|
||||||
|
if (canPersist) {
|
||||||
|
drawioRef.current?.status({ message: "", modified: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canPersist, drawioRef, handleDiagramAutoSave],
|
||||||
|
)
|
||||||
|
|
||||||
const handleDarkModeChange = () => {
|
const handleDarkModeChange = () => {
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
@@ -100,32 +115,35 @@ export default function Home() {
|
|||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
const handleDrawioUiChange = () => {
|
||||||
localStorage.setItem("drawio-theme", theme)
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
setDrawioUi(theme)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
|
setDrawioUi(newUi)
|
||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile. No panel is remounted when crossing the breakpoint, so
|
// Check mobile - reset draw.io before crossing breakpoint
|
||||||
// the draw.io ready state and the chat's turn stay as they are.
|
const isInitialRenderRef = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
setIsMobile(window.innerWidth < 768)
|
const newIsMobile = window.innerWidth < 768
|
||||||
|
if (
|
||||||
|
!isInitialRenderRef.current &&
|
||||||
|
newIsMobile !== isMobileRef.current
|
||||||
|
) {
|
||||||
|
setIsDrawioReady(false)
|
||||||
|
resetDrawioReady()
|
||||||
|
}
|
||||||
|
isMobileRef.current = newIsMobile
|
||||||
|
isInitialRenderRef.current = false
|
||||||
|
setIsMobile(newIsMobile)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [])
|
}, [resetDrawioReady])
|
||||||
|
|
||||||
// Give the chat panel the size of this side of the breakpoint. It is
|
|
||||||
// open on both sides: the mobile panel cannot be collapsed, and one
|
|
||||||
// collapsed on desktop comes back open
|
|
||||||
useEffect(() => {
|
|
||||||
chatPanelRef.current?.resize(isMobile ? 50 : 33)
|
|
||||||
setIsChatVisible(true)
|
|
||||||
}, [isMobile])
|
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -179,24 +197,29 @@ export default function Home() {
|
|||||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
autosave
|
autosave
|
||||||
onAutoSave={handleDiagramAutoSave}
|
onAutoSave={handleDrawioAutoSave}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
|
configuration={
|
||||||
|
canPersist
|
||||||
|
? { confirmExit: false }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: false,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
|
// Disable modified tracking only when persistence is available
|
||||||
|
...(canPersist && {
|
||||||
|
modified: false,
|
||||||
|
keepmodified: false,
|
||||||
|
}),
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark:
|
dark: darkMode,
|
||||||
darkMode || drawioUi === "dark",
|
lang: currentLang,
|
||||||
// draw.io names Traditional Chinese "zh-tw"
|
|
||||||
lang:
|
|
||||||
currentLang === "zh-Hant"
|
|
||||||
? "zh-tw"
|
|
||||||
: currentLang,
|
|
||||||
// Enable offline mode in Electron to disable external service calls
|
// Enable offline mode in Electron to disable external service calls
|
||||||
...(isElectron && {
|
...(isElectron && {
|
||||||
offline: true,
|
offline: true,
|
||||||
@@ -220,6 +243,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
key={isMobile ? "mobile" : "desktop"}
|
||||||
id="chat-panel"
|
id="chat-panel"
|
||||||
ref={chatPanelRef}
|
ref={chatPanelRef}
|
||||||
defaultSize={isMobile ? 50 : 33}
|
defaultSize={isMobile ? 50 : 33}
|
||||||
@@ -242,7 +266,7 @@ export default function Home() {
|
|||||||
isVisible={isChatVisible}
|
isVisible={isChatVisible}
|
||||||
onToggleVisibility={toggleChatPanel}
|
onToggleVisibility={toggleChatPanel}
|
||||||
drawioUi={drawioUi}
|
drawioUi={drawioUi}
|
||||||
onDrawioUiChange={handleDrawioUiChange}
|
onToggleDrawioUi={handleDrawioUiChange}
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
|||||||
@@ -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,84 +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"
|
|
||||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
// Test a model with the client's CURRENT provider state (which may be
|
|
||||||
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
|
||||||
// masked {isSet} markers, which are resolved against settings.json — so
|
|
||||||
// testing works both before and after saving.
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
|
|
||||||
let body: { provider?: unknown; modelId?: string }
|
|
||||||
try {
|
|
||||||
body = await req.json()
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = AdminProviderSchema.safeParse(body.provider)
|
|
||||||
if (!parsed.success || !body.modelId) {
|
|
||||||
return Response.json(
|
|
||||||
{ valid: false, error: "Invalid provider or model" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
|
||||||
// the endpoint it would be sent to (provider + baseUrl) still matches
|
|
||||||
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
|
||||||
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
|
||||||
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
|
||||||
const sameEndpoint =
|
|
||||||
stored &&
|
|
||||||
stored.provider === parsed.data.provider &&
|
|
||||||
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
|
||||||
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
|
||||||
const [resolved] = mergeSecrets(
|
|
||||||
[parsed.data],
|
|
||||||
sameEndpoint && stored ? [stored] : [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const serverUrl = globalBaseUrl(resolved.provider)
|
|
||||||
return validateModel(
|
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
// Checked again there, in place of an access code
|
|
||||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
|
||||||
// The EdgeOne function checks the access code and Pages
|
|
||||||
// cookies, and its URL is built from the page's origin
|
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
|
||||||
cookie: req.headers.get("cookie") || "",
|
|
||||||
...(req.headers.get("origin") && {
|
|
||||||
origin: req.headers.get("origin") as string,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: resolved.provider,
|
|
||||||
apiKey: resolved.apiKey,
|
|
||||||
// Without a URL of its own, chat sends the entry's key to
|
|
||||||
// the server's <P>_BASE_URL: test that endpoint, not
|
|
||||||
// another one. It is the server's own, which chat uses
|
|
||||||
// without the checks for a URL a user typed.
|
|
||||||
baseUrl: resolved.baseUrl || serverUrl,
|
|
||||||
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
|
||||||
modelId: body.modelId,
|
|
||||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
|
||||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
|
||||||
awsRegion: resolved.awsRegion,
|
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+406
-423
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
|||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
format: z.enum(["drawio", "png", "svg"]),
|
||||||
sessionId: z.string().min(1).max(200).optional(),
|
sessionId: z.string().min(1).max(200).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+12
-96
@@ -1,46 +1,12 @@
|
|||||||
import { extractFromHtml } from "@extractus/article-extractor"
|
import { extract } from "@extractus/article-extractor"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
import { readLimitedBody } from "@/lib/read-limited-body"
|
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
|
||||||
|
|
||||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
|
||||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
|
||||||
// would produce mojibake; the article-extractor library does the same detection
|
|
||||||
// when it fetches the page itself, which we no longer rely on.
|
|
||||||
function detectCharset(
|
|
||||||
contentType: string | null,
|
|
||||||
buffer: ArrayBuffer,
|
|
||||||
): string {
|
|
||||||
// 1. HTTP Content-Type header charset (most authoritative).
|
|
||||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
|
||||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
|
||||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
|
||||||
const metaCharset =
|
|
||||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
|
||||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
|
||||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
|
||||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
|
||||||
try {
|
|
||||||
new TextDecoder(charset)
|
|
||||||
return charset
|
|
||||||
} catch {
|
|
||||||
return "utf-8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const crossSite = rejectCrossSite(req)
|
|
||||||
if (crossSite) return crossSite
|
|
||||||
const accessError = checkAccessCode(req)
|
|
||||||
if (accessError) return accessError
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await req.json()
|
const { url } = await req.json()
|
||||||
|
|
||||||
@@ -61,61 +27,28 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection: parse-url has no use case for fetching internal
|
// SSRF protection
|
||||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
|
||||||
if (await isPrivateUrl(url)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Cannot access private/internal URLs" },
|
{ error: "Cannot access private/internal URLs" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Fetch the page ourselves so we control redirect handling. The
|
|
||||||
// article-extractor library follows redirects internally and ignores a
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
// `redirect` option, which would let a public URL 302 to an internal
|
|
||||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
|
||||||
// redirect outright.
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}, EXTRACT_TIMEOUT_MS)
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
let html: string
|
let article
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
article = await extract(url, undefined, {
|
||||||
headers: { "User-Agent": USER_AGENT },
|
headers: {
|
||||||
redirect: "error",
|
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||||
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type")
|
|
||||||
if (contentType?.includes("application/pdf")) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
|
||||||
},
|
|
||||||
{ status: 422 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Could not fetch URL content" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
|
|
||||||
if (!buffer) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
|
||||||
},
|
|
||||||
{ status: 413 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const charset = detectCharset(contentType, buffer)
|
|
||||||
html = new TextDecoder(charset).decode(buffer)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") {
|
if (err?.name === "AbortError") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -123,26 +56,9 @@ export async function POST(req: Request) {
|
|||||||
{ status: 504 },
|
{ status: 504 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
throw err
|
||||||
// "unexpected redirect") when redirect: "error" is set.
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Could not fetch URL content" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
// Ends a download left unread (too large, PDF, error status);
|
|
||||||
// a body already read is not affected
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
|
||||||
// so map any parse error to the same 400 as the no-content case.
|
|
||||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
|
||||||
try {
|
|
||||||
article = await extractFromHtml(html, url)
|
|
||||||
} catch {
|
|
||||||
article = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!article || !article.content) {
|
if (!article || !article.content) {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
|
||||||
import { classifyLLMError } from "@/lib/llm-errors"
|
|
||||||
import {
|
|
||||||
canListModels,
|
|
||||||
listProviderModels,
|
|
||||||
ModelListError,
|
|
||||||
} from "@/lib/provider-models"
|
|
||||||
import {
|
|
||||||
allowPrivateUrls,
|
|
||||||
isPrivateUrl,
|
|
||||||
RedirectRefusedError,
|
|
||||||
redirectGuardedFetch,
|
|
||||||
} from "@/lib/ssrf-protection"
|
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
|
|
||||||
// Public lists need no key
|
|
||||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
|
||||||
"ollama",
|
|
||||||
"openrouter",
|
|
||||||
"aihubmix",
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The models a provider offers, for the "Fetch models" button in model
|
|
||||||
* settings. Answers { models: null } for providers that cannot list them,
|
|
||||||
* so the dialog keeps its suggested models.
|
|
||||||
*/
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const crossSite = rejectCrossSite(req)
|
|
||||||
if (crossSite) return crossSite
|
|
||||||
// Sends requests to a URL the client chose, so require the access code
|
|
||||||
const accessError = checkAccessCode(req)
|
|
||||||
if (accessError) return accessError
|
|
||||||
|
|
||||||
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
|
||||||
provider: ProviderName
|
|
||||||
apiKey?: string
|
|
||||||
baseUrl?: string
|
|
||||||
}
|
|
||||||
if (!canListModels(provider)) {
|
|
||||||
return NextResponse.json({ models: null })
|
|
||||||
}
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
|
||||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
|
||||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "API key is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const models = await listProviderModels(
|
|
||||||
provider,
|
|
||||||
{ apiKey, baseUrl },
|
|
||||||
(baseUrl && redirectGuardedFetch()) || fetch,
|
|
||||||
)
|
|
||||||
return NextResponse.json({ models })
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[provider-models] Listing failed:", error)
|
|
||||||
// Only our own explanations go back: the URL may be an internal
|
|
||||||
// address, whose answer or host names must not reach the caller.
|
|
||||||
// The Gateway SDK wraps them, keeping ours as the cause.
|
|
||||||
const isOwn = (e: unknown): e is Error =>
|
|
||||||
e instanceof ModelListError || e instanceof RedirectRefusedError
|
|
||||||
const cause = (error as { cause?: unknown })?.cause
|
|
||||||
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
|
||||||
const { code } = classifyLLMError(own ?? error)
|
|
||||||
return NextResponse.json({
|
|
||||||
code,
|
|
||||||
error: own?.message ?? "The model list request failed.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,8 @@
|
|||||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Output, streamText } from "ai"
|
import { streamObject } from "ai"
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
|
||||||
import { getValidationModel } from "@/lib/ai-providers"
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
import {
|
|
||||||
checkAndIncrementRequest,
|
|
||||||
isQuotaEnabled,
|
|
||||||
recordTokenUsage,
|
|
||||||
} from "@/lib/dynamo-quota-manager"
|
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
import {
|
import {
|
||||||
type ValidationResult,
|
type ValidationResult,
|
||||||
@@ -20,9 +13,6 @@ import {
|
|||||||
|
|
||||||
export const maxDuration = 30
|
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 {
|
interface ValidateDiagramRequest {
|
||||||
imageData: string // Base64 PNG data URL
|
imageData: string // Base64 PNG data URL
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@@ -35,20 +25,25 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A fixed result in the text format useObject reads */
|
/**
|
||||||
|
* Create a streaming response for useObject compatibility.
|
||||||
|
* useObject expects text stream format, not plain JSON.
|
||||||
|
*/
|
||||||
function createStreamingResponse(result: ValidationResult): Response {
|
function createStreamingResponse(result: ValidationResult): Response {
|
||||||
return new Response(JSON.stringify(result), {
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
// Stream the JSON as text (useObject parses this)
|
||||||
|
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return new Response(stream, {
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
|
||||||
if (crossSite) return crossSite
|
|
||||||
// Uses the server's model credentials, so require the access code
|
|
||||||
const accessError = checkAccessCode(req)
|
|
||||||
if (accessError) return accessError
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if VLM validation is enabled (default: true)
|
// Check if VLM validation is enabled (default: true)
|
||||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
@@ -77,42 +72,6 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Image data too large" },
|
|
||||||
{ status: 413 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// It runs the server's vision model: with the quota on, the daily
|
|
||||||
// and per-minute token limits apply, and its tokens are counted. Not
|
|
||||||
// the request limit, which is for chats: the day's last chat still
|
|
||||||
// gets its check, and a check does not count as a chat.
|
|
||||||
const userId = getUserIdFromRequest(req)
|
|
||||||
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
|
|
||||||
if (countsQuota) {
|
|
||||||
const quotaCheck = await checkAndIncrementRequest(
|
|
||||||
userId,
|
|
||||||
{
|
|
||||||
requests: 0,
|
|
||||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
|
||||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if (!quotaCheck.allowed) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: quotaCheck.error,
|
|
||||||
type: quotaCheck.type,
|
|
||||||
used: quotaCheck.used,
|
|
||||||
limit: quotaCheck.limit,
|
|
||||||
},
|
|
||||||
{ status: 429 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the validation model
|
// Get the validation model
|
||||||
let model
|
let model
|
||||||
try {
|
try {
|
||||||
@@ -134,9 +93,9 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
) || 10000
|
) || 10000
|
||||||
|
|
||||||
// Stream the VLM response for useObject consumption
|
// Stream the VLM response for useObject consumption
|
||||||
const result = streamText({
|
const result = streamObject({
|
||||||
model,
|
model,
|
||||||
output: Output.object({ schema: ValidationResultSchema }),
|
schema: ValidationResultSchema,
|
||||||
system: VALIDATION_SYSTEM_PROMPT,
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -155,17 +114,10 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
],
|
],
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1024,
|
||||||
abortSignal: AbortSignal.timeout(timeout),
|
abortSignal: AbortSignal.timeout(timeout),
|
||||||
onFinish: ({ output, totalUsage }) => {
|
onFinish: ({ object }) => {
|
||||||
if (countsQuota && totalUsage) {
|
if (sessionId && object) {
|
||||||
recordTokenUsage(
|
|
||||||
userId,
|
|
||||||
(totalUsage.inputTokens || 0) +
|
|
||||||
(totalUsage.outputTokens || 0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (sessionId && output) {
|
|
||||||
console.log(
|
console.log(
|
||||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+273
-155
@@ -1,28 +1,20 @@
|
|||||||
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 { createVertex } from "@ai-sdk/google-vertex"
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { z } from "zod"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
|
||||||
edgeOneEndpoint,
|
|
||||||
getAIModel,
|
|
||||||
globalBaseUrl,
|
|
||||||
usesServerCredentials,
|
|
||||||
usesServerEndpoint,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import {
|
|
||||||
checkAndIncrementRequest,
|
|
||||||
isQuotaEnabled,
|
|
||||||
} from "@/lib/dynamo-quota-manager"
|
|
||||||
import { classifyLLMError } from "@/lib/llm-errors"
|
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: ProviderName
|
provider: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
modelId: string
|
modelId: string
|
||||||
@@ -30,42 +22,21 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string
|
|
||||||
// Vertex AI specific
|
// Vertex AI specific
|
||||||
vertexApiKey?: string // Express Mode API key
|
vertexApiKey?: string // Express Mode API key
|
||||||
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
|
||||||
serverBaseUrl?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEST_TIMEOUT_MS = 15_000
|
|
||||||
|
|
||||||
// Drawing works through tool calls, so the test asks for one
|
|
||||||
const PING_TOOL = tool({
|
|
||||||
description: "Report that the connection works.",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const NO_TOOL_CALL_WARNING =
|
|
||||||
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const crossSite = rejectCrossSite(req)
|
|
||||||
if (crossSite) return crossSite
|
|
||||||
// Lets the server send requests to arbitrary URLs, so require the access
|
|
||||||
// code, or the admin password (the admin panel's Test button)
|
|
||||||
const accessError = checkAccessCode(req)
|
|
||||||
if (accessError && checkAdminAuth(req)) return accessError
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
const body: ValidateRequest = await req.json()
|
||||||
const {
|
const {
|
||||||
provider,
|
provider,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
modelId,
|
modelId,
|
||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
awsSessionToken,
|
|
||||||
// Note: Express Mode only needs vertexApiKey
|
// Note: Express Mode only needs vertexApiKey
|
||||||
vertexApiKey,
|
vertexApiKey,
|
||||||
} = body
|
} = body
|
||||||
@@ -76,26 +47,9 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// EdgeOne is this site's own function, as in the chat; the admin
|
|
||||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
|
||||||
const baseUrl =
|
|
||||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
|
||||||
// The admin panel's Test of an entry without a URL sends the
|
|
||||||
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
|
||||||
// user chose, so no private-address or redirect rules
|
|
||||||
const serverUrl =
|
|
||||||
body.serverBaseUrl === true &&
|
|
||||||
!!baseUrl &&
|
|
||||||
baseUrl === globalBaseUrl(provider) &&
|
|
||||||
!checkAdminAuth(req)
|
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (
|
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
||||||
baseUrl &&
|
|
||||||
!serverUrl &&
|
|
||||||
!allowPrivateUrls() &&
|
|
||||||
(await isPrivateUrl(baseUrl))
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -129,117 +83,281 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ 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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// On the deployment's own endpoints a Test runs a model as a chat
|
let model: any
|
||||||
// does, so with the quota on it counts as a chat request (an
|
|
||||||
// admin's Test of the server's URL does not)
|
|
||||||
const userId = getUserIdFromRequest(req)
|
|
||||||
if (
|
|
||||||
isQuotaEnabled() &&
|
|
||||||
!serverUrl &&
|
|
||||||
userId !== "anonymous" &&
|
|
||||||
(await usesServerEndpoint(
|
|
||||||
provider,
|
|
||||||
normalizeBaseUrl(body.baseUrl ?? ""),
|
|
||||||
apiKey,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
|
||||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
|
||||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
|
||||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
|
||||||
})
|
|
||||||
if (!quotaCheck.allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: quotaCheck.error },
|
|
||||||
{ status: 429 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same model the chat would use. A client base URL makes it
|
switch (provider) {
|
||||||
// refuse redirects to internal hosts.
|
case "openai": {
|
||||||
const { model } = getAIModel({
|
const openai = createOpenAI({
|
||||||
provider,
|
apiKey,
|
||||||
modelId,
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
apiKey,
|
})
|
||||||
baseUrl,
|
model = openai.chat(modelId)
|
||||||
trustedBaseUrl: serverUrl,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
awsRegion,
|
|
||||||
// Temporary AWS credentials need it, as in the chat
|
|
||||||
awsSessionToken,
|
|
||||||
vertexApiKey,
|
|
||||||
// EdgeOne checks the Pages cookies and the access code
|
|
||||||
...(provider === "edgeone" && {
|
|
||||||
headers: {
|
|
||||||
cookie: req.headers.get("cookie") || "",
|
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Streaming, like the chat (some models only stream). Stop at the
|
|
||||||
// first tool call; a reasoning model that runs out of tokens first
|
|
||||||
// proves the connection but not tool support.
|
|
||||||
const startTime = Date.now()
|
|
||||||
const result = streamText({
|
|
||||||
model,
|
|
||||||
prompt: "Call the ping tool.",
|
|
||||||
tools: { ping: PING_TOOL },
|
|
||||||
maxOutputTokens: 1024,
|
|
||||||
maxRetries: 0,
|
|
||||||
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
|
||||||
})
|
|
||||||
let calledTool = false
|
|
||||||
let finishReason: string | undefined
|
|
||||||
for await (const part of result.fullStream) {
|
|
||||||
if (part.type === "error") throw part.error
|
|
||||||
// The timeout ends the stream with an abort part, not an error
|
|
||||||
if (part.type === "abort") {
|
|
||||||
const timeout = new Error(
|
|
||||||
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
|
||||||
)
|
|
||||||
timeout.name = "TimeoutError"
|
|
||||||
throw timeout
|
|
||||||
}
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
calledTool = true
|
|
||||||
break
|
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 "vertexai": {
|
||||||
|
const vertex = createVertex({
|
||||||
|
apiKey: vertexApiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = vertex(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "azure": {
|
||||||
|
const azure = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: baseUrl,
|
||||||
|
})
|
||||||
|
model = azure.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "bedrock": {
|
||||||
|
const bedrock = createAmazonBedrock({
|
||||||
|
accessKeyId: awsAccessKeyId,
|
||||||
|
secretAccessKey: awsSecretAccessKey,
|
||||||
|
region: awsRegion,
|
||||||
|
})
|
||||||
|
model = bedrock(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "openrouter": {
|
||||||
|
const openrouter = createOpenRouter({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = openrouter(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "deepseek": {
|
||||||
|
if (baseUrl || apiKey) {
|
||||||
|
const ds = createDeepSeek({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = ds(modelId)
|
||||||
|
} else {
|
||||||
|
model = deepseek(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "siliconflow": {
|
||||||
|
const sf = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||||
|
})
|
||||||
|
model = sf.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ollama": {
|
||||||
|
const ollama = createOllama({
|
||||||
|
baseURL: baseUrl || "http://localhost:11434",
|
||||||
|
})
|
||||||
|
model = ollama(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "gateway": {
|
||||||
|
const gw = createGateway({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = gw(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "edgeone": {
|
||||||
|
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
||||||
|
// Need to pass cookies for EdgeOne Pages authentication
|
||||||
|
const cookieHeader = req.headers.get("cookie") || ""
|
||||||
|
const edgeone = createOpenAI({
|
||||||
|
apiKey: "edgeone", // EdgeOne doesn't require API key
|
||||||
|
baseURL: baseUrl || "/api/edgeai",
|
||||||
|
headers: {
|
||||||
|
cookie: cookieHeader,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
model = edgeone.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sglang": {
|
||||||
|
// SGLang is OpenAI-compatible
|
||||||
|
const sglang = createOpenAI({
|
||||||
|
apiKey: apiKey || "not-needed",
|
||||||
|
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||||
|
})
|
||||||
|
model = sglang.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "doubao": {
|
||||||
|
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||||
|
const doubaoBaseUrl =
|
||||||
|
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("deepseek") ||
|
||||||
|
lowerModelId.includes("kimi")
|
||||||
|
) {
|
||||||
|
const doubao = createDeepSeek({
|
||||||
|
apiKey,
|
||||||
|
baseURL: doubaoBaseUrl,
|
||||||
|
})
|
||||||
|
model = doubao(modelId)
|
||||||
|
} else {
|
||||||
|
const doubao = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: doubaoBaseUrl,
|
||||||
|
})
|
||||||
|
model = doubao.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "modelscope": {
|
||||||
|
const baseURL =
|
||||||
|
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: modelId,
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Say 'OK'" },
|
||||||
|
],
|
||||||
|
max_tokens: 20,
|
||||||
|
stream: true,
|
||||||
|
enable_thinking: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`ModelScope API error (${response.status}): ${errorText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type") || ""
|
||||||
|
const isValidStreamingResponse =
|
||||||
|
response.status === 200 &&
|
||||||
|
(contentType.includes("text/event-stream") ||
|
||||||
|
contentType.includes("application/json"))
|
||||||
|
|
||||||
|
if (!isValidStreamingResponse) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response format: ${contentType}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
response.body.cancel().catch(() => {
|
||||||
|
/* Ignore cancellation errors */
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
responseTime,
|
||||||
|
note: "ModelScope model validated (using streaming API)",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[validate-model] ModelScope validation failed:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a minimal test request
|
||||||
|
const startTime = Date.now()
|
||||||
|
await generateText({
|
||||||
|
model,
|
||||||
|
prompt: "Say 'OK'",
|
||||||
|
maxOutputTokens: 20,
|
||||||
|
})
|
||||||
const responseTime = Date.now() - startTime
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
responseTime,
|
responseTime,
|
||||||
...(!calledTool &&
|
|
||||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[validate-model] Error:", error)
|
console.error("[validate-model] Error:", error)
|
||||||
|
|
||||||
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(
|
return NextResponse.json(
|
||||||
{ valid: false, code, error: message },
|
{ valid: false, error: errorMessage },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ 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) {
|
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(
|
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 },
|
{ status: 401 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-8
@@ -1,18 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false
|
||||||
"includes": [
|
|
||||||
"**",
|
|
||||||
"!public",
|
|
||||||
"!packages/mcp-server/src/preview",
|
|
||||||
"!lib/model-catalog.json"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Cloud } from "lucide-react"
|
import { Cloud } from "lucide-react"
|
||||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|||||||
export const ModelSelectorList = ({
|
export const ModelSelectorList = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorListProps) => {
|
}: ModelSelectorListProps) => (
|
||||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
<div className="relative">
|
||||||
const [showShadow, setShowShadow] = useState(false)
|
<CommandList
|
||||||
|
className={cn(
|
||||||
useEffect(() => {
|
// Hide scrollbar on all platforms
|
||||||
const listElement = listRef.current
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
if (!listElement) return
|
className,
|
||||||
|
)}
|
||||||
const checkScroll = () => {
|
{...props}
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
/>
|
||||||
// Show shadow if there is more content below
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
// Using a small threshold to handle fractional pixel rendering
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||||
setShowShadow(
|
</div>
|
||||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll()
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
listElement.addEventListener("scroll", checkScroll)
|
|
||||||
window.addEventListener("resize", checkScroll)
|
|
||||||
|
|
||||||
// Observe content changes (e.g. async loading of items)
|
|
||||||
const observer = new MutationObserver(checkScroll)
|
|
||||||
observer.observe(listElement, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
listElement.removeEventListener("scroll", checkScroll)
|
|
||||||
window.removeEventListener("resize", checkScroll)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<CommandList
|
|
||||||
ref={listRef}
|
|
||||||
className={cn(
|
|
||||||
// Hide scrollbar on all platforms
|
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
|
||||||
showShadow ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export default function ExamplePanel({
|
|||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
|
||||||
|
|
||||||
const handleReplicateFlowchart = async () => {
|
const handleReplicateFlowchart = async () => {
|
||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.")
|
||||||
@@ -126,31 +125,32 @@ export default function ExamplePanel({
|
|||||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||||
{!minimal && (
|
{!minimal && (
|
||||||
<>
|
<>
|
||||||
{/* MCP Server Notice, hidden on self-hosted deployments */}
|
{/* MCP Server Notice */}
|
||||||
{!isSelfHosted && (
|
<a
|
||||||
<a
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
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"
|
||||||
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="flex items-center gap-3">
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||||
<div className="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" />
|
||||||
<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>
|
</div>
|
||||||
</a>
|
<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>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.examples.mcpDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* Welcome section */}
|
{/* Welcome section */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
|
|||||||
+58
-119
@@ -1,19 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Link,
|
Link,
|
||||||
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
Square,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
type SetStateAction,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
@@ -22,7 +19,6 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
@@ -43,20 +39,9 @@ import { FilePreviewList } from "./file-preview-list"
|
|||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
const MAX_FILES = 5
|
const MAX_FILES = 5
|
||||||
// Image formats every supported model provider accepts (SVG is read as text)
|
|
||||||
const SUPPORTED_IMAGE_TYPES = [
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
]
|
|
||||||
|
|
||||||
function isValidFileType(file: File): boolean {
|
function isValidFileType(file: File): boolean {
|
||||||
return (
|
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||||
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
|
||||||
isPdfFile(file) ||
|
|
||||||
isTextFile(file)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -169,7 +154,6 @@ interface ChatInputProps {
|
|||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
onStop?: () => void
|
|
||||||
files?: File[]
|
files?: File[]
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void
|
||||||
pdfData?: Map<
|
pdfData?: Map<
|
||||||
@@ -177,7 +161,7 @@ interface ChatInputProps {
|
|||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<string, UrlData>
|
urlData?: Map<string, UrlData>
|
||||||
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -185,8 +169,8 @@ interface ChatInputProps {
|
|||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
onConfigureModels?: () => void
|
|
||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
|
onConfigureModels?: () => void
|
||||||
// Focus control props
|
// Focus control props
|
||||||
shouldFocus?: boolean
|
shouldFocus?: boolean
|
||||||
onFocused?: () => void
|
onFocused?: () => void
|
||||||
@@ -199,7 +183,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
status,
|
status,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onStop,
|
|
||||||
files = [],
|
files = [],
|
||||||
onFileChange = () => {},
|
onFileChange = () => {},
|
||||||
pdfData = new Map(),
|
pdfData = new Map(),
|
||||||
@@ -210,8 +193,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
models = [],
|
models = [],
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onModelSelect = () => {},
|
onModelSelect = () => {},
|
||||||
onConfigureModels,
|
|
||||||
showUnvalidatedModels = false,
|
showUnvalidatedModels = false,
|
||||||
|
onConfigureModels = () => {},
|
||||||
shouldFocus = false,
|
shouldFocus = false,
|
||||||
onFocused,
|
onFocused,
|
||||||
},
|
},
|
||||||
@@ -251,17 +234,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
(status === "streaming" || status === "submitted") && !error
|
(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 adjustTextareaHeight = useCallback(() => {
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current
|
||||||
@@ -299,9 +276,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
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 shouldSend =
|
const shouldSend =
|
||||||
sendShortcut === "enter"
|
sendShortcut === "enter"
|
||||||
? e.key === "Enter" &&
|
? e.key === "Enter" &&
|
||||||
@@ -313,12 +287,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.currentTarget.closest("form")
|
const form = e.currentTarget.closest("form")
|
||||||
if (
|
if (form && input.trim() && !isDisabled) {
|
||||||
form &&
|
|
||||||
input.trim() &&
|
|
||||||
!isDisabled &&
|
|
||||||
!isExtractingAttachments
|
|
||||||
) {
|
|
||||||
form.requestSubmit()
|
form.requestSubmit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,9 +375,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
if (isDisabled) return
|
if (isDisabled) return
|
||||||
|
|
||||||
// Let validateFiles show a toast for unsupported types
|
const droppedFiles = e.dataTransfer.files
|
||||||
|
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
|
isValidFileType(file),
|
||||||
|
)
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
Array.from(e.dataTransfer.files),
|
supportedFiles,
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -423,34 +396,33 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
setIsExtractingUrl(true)
|
setIsExtractingUrl(true)
|
||||||
|
|
||||||
// Use functional updates so a removal or send made while extracting
|
|
||||||
// is not overwritten when the request finishes
|
|
||||||
try {
|
try {
|
||||||
onUrlChange((prev) =>
|
const existing = urlData
|
||||||
new Map(prev).set(url, {
|
? new Map(urlData)
|
||||||
url,
|
: new Map<string, UrlData>()
|
||||||
title: url,
|
existing.set(url, {
|
||||||
content: "",
|
url,
|
||||||
charCount: 0,
|
title: url,
|
||||||
isExtracting: true,
|
content: "",
|
||||||
}),
|
charCount: 0,
|
||||||
)
|
isExtracting: true,
|
||||||
|
})
|
||||||
|
onUrlChange(existing)
|
||||||
|
|
||||||
const data = await extractUrlContent(url)
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
// Skip if the URL was removed while extracting
|
const newUrlData = new Map(existing)
|
||||||
onUrlChange((prev) =>
|
newUrlData.set(url, data)
|
||||||
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
onUrlChange(newUrlData)
|
||||||
)
|
|
||||||
|
|
||||||
setShowUrlDialog(false)
|
setShowUrlDialog(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Remove the URL from the data map on error
|
// Remove the URL from the data map on error
|
||||||
onUrlChange((prev) => {
|
const newUrlData = urlData
|
||||||
const next = new Map(prev)
|
? new Map(urlData)
|
||||||
next.delete(url)
|
: new Map<string, UrlData>()
|
||||||
return next
|
newUrlData.delete(url)
|
||||||
})
|
onUrlChange(newUrlData)
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{error instanceof Error
|
{error instanceof Error
|
||||||
@@ -465,7 +437,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id="chat-form"
|
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className={`w-full transition-all duration-200 ${
|
className={`w-full transition-all duration-200 ${
|
||||||
isDragging
|
isDragging
|
||||||
@@ -486,12 +457,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
urlData={urlData}
|
urlData={urlData}
|
||||||
onRemoveUrl={
|
onRemoveUrl={
|
||||||
onUrlChange
|
onUrlChange
|
||||||
? (url) =>
|
? (url) => {
|
||||||
onUrlChange((prev) => {
|
const next = new Map(urlData)
|
||||||
const next = new Map(prev)
|
next.delete(url)
|
||||||
next.delete(url)
|
onUrlChange(next)
|
||||||
return next
|
}
|
||||||
})
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -566,24 +536,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSaveAsTemplate(true)}
|
|
||||||
disabled={isDisabled || !input.trim()}
|
|
||||||
tooltipContent={dict.templates.saveAsTemplate}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<BookmarkPlus className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/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
|
multiple
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
@@ -597,34 +555,24 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
showUnvalidatedModels={showUnvalidatedModels}
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
{(status === "streaming" || status === "submitted") &&
|
<Button
|
||||||
onStop ? (
|
type="submit"
|
||||||
<Button
|
disabled={isDisabled || !input.trim()}
|
||||||
type="button"
|
size="sm"
|
||||||
onClick={onStop}
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
size="sm"
|
aria-label={
|
||||||
variant="destructive"
|
isDisabled ? dict.chat.sending : dict.chat.send
|
||||||
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
}
|
||||||
aria-label={dict.chat.stopGeneration}
|
>
|
||||||
>
|
{isDisabled ? (
|
||||||
<Square className="h-4 w-4" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</Button>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<Button
|
<Send className="h-4 w-4 mr-1.5" />
|
||||||
type="submit"
|
{dict.chat.send}
|
||||||
disabled={
|
</>
|
||||||
isDisabled ||
|
)}
|
||||||
isExtractingAttachments ||
|
</Button>
|
||||||
!input.trim()
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
|
||||||
aria-label={dict.chat.send}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 mr-1.5" />
|
|
||||||
{dict.chat.send}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HistoryDialog
|
<HistoryDialog
|
||||||
@@ -642,8 +590,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
dict.save.savedSuccessfully,
|
dict.save.savedSuccessfully,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
defaultFilename={`diagram-${new Date()
|
||||||
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
.toISOString()
|
||||||
|
.slice(0, 10)}`}
|
||||||
/>
|
/>
|
||||||
{onUrlChange && (
|
{onUrlChange && (
|
||||||
<UrlInputDialog
|
<UrlInputDialog
|
||||||
@@ -653,16 +602,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
isExtracting={isExtractingUrl}
|
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>
|
</form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+227
-248
@@ -3,7 +3,6 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
@@ -26,24 +26,21 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -130,14 +127,12 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
|
|
||||||
export const APPENDED_FILE_SECTIONS_PATTERN =
|
|
||||||
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
|
||||||
|
|
||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
|
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
||||||
|
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
@@ -149,8 +144,6 @@ interface SessionMetadata {
|
|||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
// Shown on an error that a model setting can fix (bad key, unknown model)
|
|
||||||
onOpenModelConfig?: () => void
|
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||||
@@ -166,15 +159,10 @@ interface ChatMessageDisplayProps {
|
|||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||||
validationStates?: Record<string, ValidationState>
|
validationStates?: Record<string, ValidationState>
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
onImproveWithSuggestions?: (feedback: string) => void
|
||||||
onSendTemplate?: (
|
|
||||||
template: import("@/lib/template-storage").Template,
|
|
||||||
) => void
|
|
||||||
currentInput?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
messages,
|
messages,
|
||||||
onOpenModelConfig,
|
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
@@ -190,28 +178,9 @@ export function ChatMessageDisplay({
|
|||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
validationStates = {},
|
validationStates = {},
|
||||||
onImproveWithSuggestions,
|
onImproveWithSuggestions,
|
||||||
onSendTemplate,
|
|
||||||
currentInput = "",
|
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
// The thinking header in the page language
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
|
|
||||||
if (isStreaming || duration === 0) {
|
|
||||||
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
|
|
||||||
}
|
|
||||||
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
{duration === 1
|
|
||||||
? dict.reasoning.thoughtForOne
|
|
||||||
: dict.reasoning.thoughtFor.replace(
|
|
||||||
"{duration}",
|
|
||||||
String(duration),
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
@@ -231,6 +200,20 @@ export function ChatMessageDisplay({
|
|||||||
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
||||||
}
|
}
|
||||||
}, [messages.length, processedToolCalls])
|
}, [messages.length, processedToolCalls])
|
||||||
|
// Debounce streaming diagram updates - store pending XML and timeout
|
||||||
|
const pendingXmlRef = useRef<string | null>(null)
|
||||||
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
|
||||||
|
// Refs for edit_diagram streaming
|
||||||
|
const pendingEditRef = useRef<{
|
||||||
|
operations: DiagramOperation[]
|
||||||
|
toolCallId: string
|
||||||
|
} | null>(null)
|
||||||
|
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
@@ -254,10 +237,6 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
// Track "Save as Template" dialog
|
|
||||||
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
|
||||||
string | null
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
const setCopyState = (
|
const setCopyState = (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -352,30 +331,73 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming preview of display_diagram: draw the complete cells written
|
|
||||||
// so far. The tool handler validates and loads the final diagram.
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string) => {
|
(xml: string, showToast = false) => {
|
||||||
const completeCells = extractCompleteMxCells(xml || "")
|
let currentXml = xml || ""
|
||||||
if (!completeCells) return
|
|
||||||
const convertedXml = convertToLegalXml(completeCells)
|
|
||||||
if (convertedXml === previousXML.current) return
|
|
||||||
|
|
||||||
// Skip this update while the cells written so far don't parse
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
const testDoc = new DOMParser().parseFromString(
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
`<root>${convertedXml}</root>`,
|
if (!showToast) {
|
||||||
"text/xml",
|
const completeCells = extractCompleteMxCells(currentXml)
|
||||||
)
|
if (!completeCells) {
|
||||||
if (testDoc.querySelector("parsererror")) return
|
return
|
||||||
|
}
|
||||||
|
currentXml = completeCells
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const convertedXml = convertToLegalXml(currentXml)
|
||||||
// An empty canvas gets a default mxfile to put the cells in
|
if (convertedXml !== previousXML.current) {
|
||||||
const baseXML = chartXML || BLANK_MXFILE
|
// Parse and validate XML BEFORE calling replaceNodes
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const parser = new DOMParser()
|
||||||
previousXML.current = convertedXml
|
// Wrap in root element for parsing multiple mxCell elements
|
||||||
onDisplayChart(replacedXML, true)
|
const testDoc = parser.parseFromString(
|
||||||
} catch (error) {
|
`<root>${convertedXml}</root>`,
|
||||||
console.error("Error processing XML:", error)
|
"text/xml",
|
||||||
|
)
|
||||||
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
|
if (parseError) {
|
||||||
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
|
if (showToast) {
|
||||||
|
toast.error(dict.errors.malformedXml)
|
||||||
|
}
|
||||||
|
return // Skip this update
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
||||||
|
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
||||||
|
const baseXML =
|
||||||
|
chartXML ||
|
||||||
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
|
// The quick DOM parse check above catches malformed XML
|
||||||
|
// Full validation runs on final output (showToast=true)
|
||||||
|
if (!showToast) {
|
||||||
|
previousXML.current = convertedXml
|
||||||
|
onDisplayChart(replacedXML, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final output: run full validation and auto-fix
|
||||||
|
const validation = validateAndFixXml(replacedXML)
|
||||||
|
if (validation.valid) {
|
||||||
|
previousXML.current = convertedXml
|
||||||
|
// Use fixed XML if available, otherwise use original
|
||||||
|
const xmlToLoad = validation.fixed || replacedXML
|
||||||
|
onDisplayChart(xmlToLoad, true)
|
||||||
|
} else {
|
||||||
|
toast.error(dict.errors.validationFailed)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing XML:", error)
|
||||||
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
|
if (showToast) {
|
||||||
|
toast.error(dict.errors.failedToProcess)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
@@ -383,7 +405,6 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
// Track previous message count to detect bulk loads vs streaming
|
// Track previous message count to detect bulk loads vs streaming
|
||||||
const prevMessageCountRef = useRef(0)
|
const prevMessageCountRef = useRef(0)
|
||||||
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && messages.length > 0) {
|
if (messagesEndRef.current && messages.length > 0) {
|
||||||
@@ -397,17 +418,8 @@ export function ChatMessageDisplay({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle scroll during streaming to avoid layout thrashing
|
// Single message added - smooth scroll
|
||||||
// Leading + trailing: scroll immediately, then once more after cooldown
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
if (!scrollThrottleRef.current) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
||||||
scrollThrottleRef.current = setTimeout(() => {
|
|
||||||
scrollThrottleRef.current = null
|
|
||||||
messagesEndRef.current?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
})
|
|
||||||
}, 150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
@@ -422,19 +434,8 @@ export function ChatMessageDisplay({
|
|||||||
// Previous messages are already processed and won't change
|
// Previous messages are already processed and won't change
|
||||||
const messagesToProcess =
|
const messagesToProcess =
|
||||||
messages.length > 0 ? [messages[messages.length - 1]] : []
|
messages.length > 0 ? [messages[messages.length - 1]] : []
|
||||||
// The diagram without streamed previews, as loaded last: the tool
|
|
||||||
// handler's result of an earlier edit is there before the chartXML
|
|
||||||
// state catches up. Undoing a failed edit's preview below changes it
|
|
||||||
// too, and an edit streaming right after must start from the undone
|
|
||||||
// diagram.
|
|
||||||
let baseXml = chartXMLRef.current
|
|
||||||
|
|
||||||
messagesToProcess.forEach((message) => {
|
messagesToProcess.forEach((message) => {
|
||||||
// Messages restored from a saved session were applied before it was
|
|
||||||
// saved; the saved diagram is authoritative, so don't replay them
|
|
||||||
const isRestoredMessage =
|
|
||||||
loadedMessageIdsRef?.current.has(message.id) ?? false
|
|
||||||
|
|
||||||
if (message.parts) {
|
if (message.parts) {
|
||||||
message.parts.forEach((part) => {
|
message.parts.forEach((part) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
@@ -452,87 +453,76 @@ export function ChatMessageDisplay({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRestoredMessage) return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
part.type !== "tool-display_diagram" &&
|
part.type === "tool-display_diagram" &&
|
||||||
part.type !== "tool-edit_diagram"
|
input?.xml
|
||||||
) {
|
) {
|
||||||
return
|
const xml = input.xml as string
|
||||||
}
|
|
||||||
|
|
||||||
// Failed or stopped: if the original XML is still
|
|
||||||
// stored, the tool handler never ran (invalid JSON,
|
|
||||||
// or the user pressed stop), so undo the streamed
|
|
||||||
// preview here. Invalid JSON leaves no input, so
|
|
||||||
// check this first.
|
|
||||||
if (state === "output-error") {
|
|
||||||
const originalXml =
|
|
||||||
editDiagramOriginalXmlRef.current.get(
|
|
||||||
toolCallId,
|
|
||||||
)
|
|
||||||
if (originalXml) {
|
|
||||||
editDiagramOriginalXmlRef.current.delete(
|
|
||||||
toolCallId,
|
|
||||||
)
|
|
||||||
onDisplayChart(originalXml, true)
|
|
||||||
baseXml = originalXml
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input complete, or the tool handler, a stop or an
|
|
||||||
// error took the call already: the tool handler loads
|
|
||||||
// the checked diagram (with the original XML). The
|
|
||||||
// messages update at most every 150 ms (useChat
|
|
||||||
// throttle in chat-panel), so they can still show the
|
|
||||||
// call streaming after that.
|
|
||||||
if (
|
|
||||||
state !== "input-streaming" ||
|
|
||||||
processedToolCalls.current.has(toolCallId)
|
|
||||||
) {
|
|
||||||
processedToolCalls.current.add(toolCallId)
|
|
||||||
lastProcessedXmlRef.current.delete(toolCallId)
|
|
||||||
lastProcessedXmlRef.current.delete(
|
|
||||||
`${toolCallId}-opCount`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "tool-display_diagram") {
|
|
||||||
const xml = input?.xml as string | undefined
|
|
||||||
// Skip if XML hasn't changed since last processing
|
// Skip if XML hasn't changed since last processing
|
||||||
if (
|
const lastXml =
|
||||||
!xml ||
|
lastProcessedXmlRef.current.get(toolCallId)
|
||||||
lastProcessedXmlRef.current.get(toolCallId) ===
|
if (lastXml === xml) {
|
||||||
xml
|
return // Skip redundant processing
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Keep the diagram from before the preview, to
|
|
||||||
// undo it on a stop or an error
|
|
||||||
if (
|
if (
|
||||||
!editDiagramOriginalXmlRef.current.has(
|
state === "input-streaming" ||
|
||||||
toolCallId,
|
state === "input-available"
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
editDiagramOriginalXmlRef.current.set(
|
// Debounce streaming updates - queue the XML and process after delay
|
||||||
toolCallId,
|
pendingXmlRef.current = xml
|
||||||
baseXml || BLANK_MXFILE,
|
|
||||||
)
|
if (!debounceTimeoutRef.current) {
|
||||||
|
// No pending timeout - set one up
|
||||||
|
debounceTimeoutRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
|
const pendingXml =
|
||||||
|
pendingXmlRef.current
|
||||||
|
debounceTimeoutRef.current = null
|
||||||
|
pendingXmlRef.current = null
|
||||||
|
if (pendingXml) {
|
||||||
|
handleDisplayChart(
|
||||||
|
pendingXml,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
lastProcessedXmlRef.current.set(
|
||||||
|
toolCallId,
|
||||||
|
pendingXml,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
STREAMING_DEBOUNCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
state === "output-available" &&
|
||||||
|
!processedToolCalls.current.has(toolCallId)
|
||||||
|
) {
|
||||||
|
// Final output - process immediately (clear any pending debounce)
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current)
|
||||||
|
debounceTimeoutRef.current = null
|
||||||
|
pendingXmlRef.current = null
|
||||||
|
}
|
||||||
|
// Show toast only if final XML is malformed
|
||||||
|
handleDisplayChart(xml, true)
|
||||||
|
processedToolCalls.current.add(toolCallId)
|
||||||
|
// Clean up the ref entry - tool is complete, no longer needed
|
||||||
|
lastProcessedXmlRef.current.delete(toolCallId)
|
||||||
}
|
}
|
||||||
handleDisplayChart(xml)
|
|
||||||
lastProcessedXmlRef.current.set(toolCallId, xml)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle edit_diagram streaming - apply operations incrementally for preview
|
// Handle edit_diagram streaming - apply operations incrementally for preview
|
||||||
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
||||||
if (part.type === "tool-edit_diagram") {
|
if (
|
||||||
if (!input?.operations) return
|
part.type === "tool-edit_diagram" &&
|
||||||
|
input?.operations
|
||||||
|
) {
|
||||||
const completeOps = getCompleteOperations(
|
const completeOps = getCompleteOperations(
|
||||||
input.operations as DiagramOperation[],
|
input.operations as DiagramOperation[],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (completeOps.length === 0) return
|
if (completeOps.length === 0) return
|
||||||
|
|
||||||
// Capture original XML when streaming starts (store in shared ref)
|
// Capture original XML when streaming starts (store in shared ref)
|
||||||
@@ -541,7 +531,7 @@ export function ChatMessageDisplay({
|
|||||||
toolCallId,
|
toolCallId,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (!baseXml) {
|
if (!chartXML) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[edit_diagram streaming] No chart XML available",
|
"[edit_diagram streaming] No chart XML available",
|
||||||
)
|
)
|
||||||
@@ -549,9 +539,10 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
editDiagramOriginalXmlRef.current.set(
|
editDiagramOriginalXmlRef.current.set(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
baseXml,
|
chartXML,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalXml =
|
const originalXml =
|
||||||
editDiagramOriginalXmlRef.current.get(
|
editDiagramOriginalXmlRef.current.get(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -559,37 +550,95 @@ export function ChatMessageDisplay({
|
|||||||
if (!originalXml) return
|
if (!originalXml) return
|
||||||
|
|
||||||
// Skip if no change from last processed state
|
// Skip if no change from last processed state
|
||||||
const countKey = `${toolCallId}-opCount`
|
const lastCount = lastProcessedXmlRef.current.get(
|
||||||
const opCount = String(completeOps.length)
|
toolCallId + "-opCount",
|
||||||
|
)
|
||||||
|
if (lastCount === String(completeOps.length)) return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lastProcessedXmlRef.current.get(countKey) ===
|
state === "input-streaming" ||
|
||||||
opCount
|
state === "input-available"
|
||||||
) {
|
) {
|
||||||
return
|
// Queue the operations for debounced processing
|
||||||
}
|
pendingEditRef.current = {
|
||||||
try {
|
operations: completeOps,
|
||||||
const { result } = applyDiagramOperations(
|
toolCallId,
|
||||||
originalXml,
|
}
|
||||||
completeOps,
|
|
||||||
)
|
if (!editDebounceTimeoutRef.current) {
|
||||||
// Load the full document so other pages stay intact
|
editDebounceTimeoutRef.current = setTimeout(
|
||||||
onDisplayChart(result, true)
|
() => {
|
||||||
lastProcessedXmlRef.current.set(
|
const pending =
|
||||||
countKey,
|
pendingEditRef.current
|
||||||
opCount,
|
editDebounceTimeoutRef.current =
|
||||||
)
|
null
|
||||||
} catch (e) {
|
pendingEditRef.current = null
|
||||||
console.warn(
|
|
||||||
"[edit_diagram streaming] Operation failed:",
|
if (pending) {
|
||||||
e instanceof Error ? e.message : e,
|
const origXml =
|
||||||
|
editDiagramOriginalXmlRef.current.get(
|
||||||
|
pending.toolCallId,
|
||||||
|
)
|
||||||
|
if (!origXml) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
result: editedXml,
|
||||||
|
} = applyDiagramOperations(
|
||||||
|
origXml,
|
||||||
|
pending.operations,
|
||||||
|
)
|
||||||
|
handleDisplayChart(
|
||||||
|
editedXml,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
lastProcessedXmlRef.current.set(
|
||||||
|
pending.toolCallId +
|
||||||
|
"-opCount",
|
||||||
|
String(
|
||||||
|
pending.operations
|
||||||
|
.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`[edit_diagram streaming] Operation failed:`,
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
STREAMING_DEBOUNCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
state === "output-available" &&
|
||||||
|
!processedToolCalls.current.has(toolCallId)
|
||||||
|
) {
|
||||||
|
// Final state - cleanup streaming refs (tool handler does final application)
|
||||||
|
if (editDebounceTimeoutRef.current) {
|
||||||
|
clearTimeout(editDebounceTimeoutRef.current)
|
||||||
|
editDebounceTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
lastProcessedXmlRef.current.delete(
|
||||||
|
toolCallId + "-opCount",
|
||||||
)
|
)
|
||||||
|
processedToolCalls.current.add(toolCallId)
|
||||||
|
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [messages, handleDisplayChart, chartXMLRef])
|
|
||||||
|
// NOTE: Don't cleanup debounce timeouts here!
|
||||||
|
// The cleanup runs on every re-render (when messages changes),
|
||||||
|
// which would cancel the timeout before it fires.
|
||||||
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
@@ -601,8 +650,6 @@ export function ChatMessageDisplay({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
dict={dict}
|
dict={dict}
|
||||||
/>
|
/>
|
||||||
) : messages.length === 0 ? null : (
|
) : messages.length === 0 ? null : (
|
||||||
@@ -700,42 +747,8 @@ export function ChatMessageDisplay({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Save as Template button - only for user messages */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setSaveAsTemplateMessageId(
|
|
||||||
message.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
dict.templates
|
|
||||||
?.saveAsTemplate ||
|
|
||||||
"Save as Template"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save as Template Dialog */}
|
|
||||||
{saveAsTemplateMessageId === message.id && (
|
|
||||||
<TemplateCreateDialog
|
|
||||||
open={true}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open)
|
|
||||||
setSaveAsTemplateMessageId(null)
|
|
||||||
}}
|
|
||||||
onSuccess={() => {
|
|
||||||
setSaveAsTemplateMessageId(null)
|
|
||||||
}}
|
|
||||||
initialPrompt={getUserOriginalText(
|
|
||||||
message,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%] min-w-0">
|
||||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||||
{message.role === "assistant" &&
|
{message.role === "assistant" &&
|
||||||
@@ -774,11 +787,7 @@ export function ChatMessageDisplay({
|
|||||||
!isRestoredMessage
|
!isRestoredMessage
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger
|
<ReasoningTrigger />
|
||||||
getThinkingMessage={
|
|
||||||
thinkingMessage
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
{
|
{
|
||||||
reasoningPart.text
|
reasoningPart.text
|
||||||
@@ -884,12 +893,8 @@ export function ChatMessageDisplay({
|
|||||||
part.type?.startsWith(
|
part.type?.startsWith(
|
||||||
"tool-",
|
"tool-",
|
||||||
)
|
)
|
||||||
// Blank text (some models send
|
|
||||||
// a lone space) gets no bubble
|
|
||||||
const isContentPart =
|
const isContentPart =
|
||||||
(part.type === "text" &&
|
part.type === "text" ||
|
||||||
part.text.trim() !==
|
|
||||||
"") ||
|
|
||||||
part.type === "file"
|
part.type === "file"
|
||||||
|
|
||||||
if (isToolPart) {
|
if (isToolPart) {
|
||||||
@@ -1239,32 +1244,6 @@ export function ChatMessageDisplay({
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
{message.role ===
|
|
||||||
"system" &&
|
|
||||||
(
|
|
||||||
message.metadata as
|
|
||||||
| {
|
|
||||||
openModelConfig?: boolean
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
)
|
|
||||||
?.openModelConfig &&
|
|
||||||
onOpenModelConfig && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={
|
|
||||||
onOpenModelConfig
|
|
||||||
}
|
|
||||||
className="mt-2 text-xs font-medium underline underline-offset-2 hover:opacity-80"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict
|
|
||||||
.errors
|
|
||||||
.llm
|
|
||||||
.openModelSettings
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+283
-529
File diff suppressed because it is too large
Load Diff
+136
-222
@@ -8,10 +8,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import Image from "next/image"
|
||||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
import { useState } from "react"
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
import ExamplePanel from "@/components/chat-example-panel"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -22,8 +21,6 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import type { Template } from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,8 +35,6 @@ interface ChatLobbyProps {
|
|||||||
onDeleteSession?: (id: string) => void
|
onDeleteSession?: (id: string) => void
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
onSendTemplate?: (template: Template) => void
|
|
||||||
currentInput?: string
|
|
||||||
dict: {
|
dict: {
|
||||||
sessionHistory?: {
|
sessionHistory?: {
|
||||||
recentChats?: string
|
recentChats?: string
|
||||||
@@ -49,10 +44,6 @@ interface ChatLobbyProps {
|
|||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
}
|
}
|
||||||
templates?: {
|
|
||||||
title?: string
|
|
||||||
myTemplates?: string
|
|
||||||
}
|
|
||||||
examples?: {
|
examples?: {
|
||||||
quickExamples?: string
|
quickExamples?: string
|
||||||
}
|
}
|
||||||
@@ -84,241 +75,164 @@ function formatSessionDate(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPanelVisibility() {
|
|
||||||
if (typeof window === "undefined")
|
|
||||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
|
||||||
return {
|
|
||||||
recentChats:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
|
||||||
myTemplates:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
|
||||||
quickExamples:
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatLobby({
|
export function ChatLobby({
|
||||||
sessions,
|
sessions,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
onSendTemplate,
|
|
||||||
currentInput = "",
|
|
||||||
dict,
|
dict,
|
||||||
}: ChatLobbyProps) {
|
}: ChatLobbyProps) {
|
||||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
// Track whether examples section is expanded (collapsed by default when there's history)
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
// Delete confirmation dialog state
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||||
|
// Search filter for history
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
// Listen for panel visibility changes from settings
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
|
||||||
window.addEventListener("panelVisibilityChange", handler)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("panelVisibilityChange", handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const hasHistory = sessions.length > 0
|
const hasHistory = sessions.length > 0
|
||||||
|
|
||||||
if (!hasHistory) {
|
if (!hasHistory) {
|
||||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
// Show full examples when no history
|
||||||
return null
|
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
{panelVisibility.myTemplates && (
|
|
||||||
<TemplatePanel
|
|
||||||
setInput={setInput}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{panelVisibility.quickExamples && (
|
|
||||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
// Show history + collapsible examples when there are sessions
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{/* Recent Chats Section */}
|
{/* Recent Chats Section */}
|
||||||
{panelVisibility.recentChats && (
|
<div className="mb-6">
|
||||||
<div className="mb-6">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
</p>
|
||||||
</p>
|
{/* Search Bar */}
|
||||||
{/* Search Bar */}
|
<div className="relative mb-3">
|
||||||
<div className="relative mb-3">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
placeholder={
|
||||||
placeholder={
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
"Search chats..."
|
||||||
"Search chats..."
|
}
|
||||||
}
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
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"
|
||||||
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 && (
|
||||||
{searchQuery && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setSearchQuery("")}
|
||||||
onClick={() => setSearchQuery("")}
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||||
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" />
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
{sessions
|
||||||
{sessions
|
.filter((session) =>
|
||||||
.filter((session) =>
|
session.title
|
||||||
session.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((session) => (
|
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
||||||
onClick={() => onSelectSession(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Ignore keys bubbling up from the delete button
|
|
||||||
if (e.target !== e.currentTarget) return
|
|
||||||
if (
|
|
||||||
e.key === "Enter" ||
|
|
||||||
e.key === " "
|
|
||||||
) {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectSession(session.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.thumbnailDataUrl ? (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={session.thumbnailDataUrl}
|
|
||||||
alt=""
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="object-contain w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<MessageSquare className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">
|
|
||||||
{session.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatSessionDate(
|
|
||||||
session.updatedAt,
|
|
||||||
dict.sessionHistory,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onDeleteSession && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSessionToDelete(session.id)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
title={dict.common.delete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.filter((s) =>
|
|
||||||
s.title
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()),
|
.includes(searchQuery.toLowerCase()),
|
||||||
).length === 0 &&
|
)
|
||||||
searchQuery && (
|
.map((session) => (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
{dict.sessionHistory?.noResults ||
|
<div
|
||||||
"No chats found"}
|
key={session.id}
|
||||||
</p>
|
role="button"
|
||||||
)}
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
).length === 0 &&
|
||||||
|
searchQuery && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{dict.sessionHistory?.noResults ||
|
||||||
|
"No chats found"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Examples Section */}
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.examples?.quickExamples || "Quick Examples"}
|
||||||
|
</span>
|
||||||
|
{examplesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{examplesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ExamplePanel
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
minimal
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Collapsible My Templates Section */}
|
|
||||||
{panelVisibility.myTemplates && (
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.templates?.myTemplates || "My Templates"}
|
|
||||||
</span>
|
|
||||||
{templatesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{templatesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<TemplatePanel
|
|
||||||
setInput={setInput}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collapsible Quick Examples Section */}
|
|
||||||
{panelVisibility.quickExamples && (
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.examples?.quickExamples || "Quick Examples"}
|
|
||||||
</span>
|
|
||||||
{examplesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{examplesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<ExamplePanel
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
minimal
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -20,17 +20,11 @@ interface ToolCallCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
// Streamed or invalid input can hold anything: show only what React can
|
|
||||||
// render (an object in place of a string would crash the whole chat)
|
|
||||||
const shown = operations.filter(
|
|
||||||
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
|
||||||
)
|
|
||||||
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{shown.map((op, index) => (
|
{operations.map((op, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${op.operation}-${text(op.cell_id)}-${index}`}
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
@@ -46,13 +40,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|||||||
{op.operation}
|
{op.operation}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
cell_id: {text(op.cell_id)}
|
cell_id: {op.cell_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{text(op.new_xml) && (
|
{op.new_xml && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
{text(op.new_xml)}
|
{op.new_xml}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -72,7 +66,7 @@ export function ToolCallCard({
|
|||||||
dict,
|
dict,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output, errorText } = part
|
const { state, input, output } = part
|
||||||
// Default to expanded for all states (user can manually collapse if needed)
|
// Default to expanded for all states (user can manually collapse if needed)
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = part.type?.replace("tool-", "")
|
||||||
@@ -98,16 +92,6 @@ export function ToolCallCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incomplete XML means the output hit the length limit, unless the user
|
|
||||||
// stopped the generation themselves. Without an input the JSON was
|
|
||||||
// broken (the server repairs JSON cut short by the limit).
|
|
||||||
const isTruncated =
|
|
||||||
state === "output-error" &&
|
|
||||||
errorText !== "Stopped by user" &&
|
|
||||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
|
||||||
typeof input?.xml === "string" &&
|
|
||||||
!isMxCellXmlComplete(input.xml)
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
let textToCopy = ""
|
let textToCopy = ""
|
||||||
|
|
||||||
@@ -177,15 +161,22 @@ export function ToolCallCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{state === "output-error" &&
|
{state === "output-error" &&
|
||||||
(isTruncated ? (
|
(() => {
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
Truncated
|
const isTruncated =
|
||||||
</span>
|
(toolName === "display_diagram" ||
|
||||||
) : (
|
toolName === "append_diagram") &&
|
||||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
!isMxCellXmlComplete(input?.xml)
|
||||||
Error
|
return isTruncated ? (
|
||||||
</span>
|
<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 && (
|
{input && Object.keys(input).length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,22 +195,7 @@ export function ToolCallCard({
|
|||||||
{input && isExpanded && (
|
{input && isExpanded && (
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
{typeof input === "object" && input.xml ? (
|
{typeof input === "object" && input.xml ? (
|
||||||
state === "input-streaming" ||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
state === "input-available" ? (
|
|
||||||
<pre
|
|
||||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
"var(--font-mono), ui-monospace, monospace",
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{input.xml}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
|
||||||
)
|
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
input.operations &&
|
input.operations &&
|
||||||
Array.isArray(input.operations) ? (
|
Array.isArray(input.operations) ? (
|
||||||
@@ -233,16 +209,23 @@ export function ToolCallCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* AI SDK stores tool errors in errorText */}
|
{output &&
|
||||||
{state === "output-error" && (errorText || output) && (
|
state === "output-error" &&
|
||||||
<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"}`}
|
const isTruncated =
|
||||||
>
|
(toolName === "display_diagram" ||
|
||||||
{isTruncated
|
toolName === "append_diagram") &&
|
||||||
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
!isMxCellXmlComplete(input?.xml)
|
||||||
: (errorText ?? output)}
|
return (
|
||||||
</div>
|
<div
|
||||||
)}
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||||
|
: output}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{/* Show get_shape_library output on success */}
|
{/* Show get_shape_library output on success */}
|
||||||
{output &&
|
{output &&
|
||||||
toolName === "get_shape_library" &&
|
toolName === "get_shape_library" &&
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
export interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
export type { DiagramOperation }
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolPartLike {
|
export interface ToolPartLike {
|
||||||
type: string
|
type: string
|
||||||
@@ -11,5 +13,4 @@ export interface ToolPartLike {
|
|||||||
operations?: DiagramOperation[]
|
operations?: DiagramOperation[]
|
||||||
} & Record<string, unknown>
|
} & Record<string, unknown>
|
||||||
output?: string
|
output?: string
|
||||||
errorText?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
import { wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
const DEV_XML_PRESETS: Record<string, string> = {
|
const DEV_XML_PRESETS: Record<string, string> = {
|
||||||
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Display the final diagram
|
// Display the final diagram
|
||||||
const prepared = prepareNewDiagram(xml)
|
const fullXml = wrapWithMxFile(xml)
|
||||||
if (prepared.ok) onDisplayChart(prepared.xml)
|
onDisplayChart(fullXml)
|
||||||
|
|
||||||
setIsSimulating(false)
|
setIsSimulating(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Image from "next/image"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
},
|
|
||||||
)
|
|
||||||
+849
-681
File diff suppressed because it is too large
Load Diff
+211
-242
@@ -27,21 +27,36 @@ import {
|
|||||||
} from "@/components/ai-elements/model-selector"
|
} from "@/components/ai-elements/model-selector"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
type FlattenedModel,
|
|
||||||
PROVIDER_LOGO_MAP,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
onSelect: (modelId: string | undefined) => void
|
onSelect: (modelId: string | undefined) => void
|
||||||
onConfigure?: () => void
|
onConfigure: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
showUnvalidatedModels?: boolean
|
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",
|
||||||
|
vertexai: "google",
|
||||||
|
doubao: "bytedance",
|
||||||
|
modelscope: "modelscope",
|
||||||
|
}
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
@@ -111,7 +126,9 @@ export function ModelSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value === "__server_default__") {
|
if (value === "__configure__") {
|
||||||
|
onConfigure()
|
||||||
|
} else if (value === "__server_default__") {
|
||||||
onSelect(undefined)
|
onSelect(undefined)
|
||||||
} else {
|
} else {
|
||||||
onSelect(value)
|
onSelect(value)
|
||||||
@@ -158,7 +175,7 @@ export function ModelSelector({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
<div ref={wrapperRef} className="inline-block">
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -167,7 +184,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
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-[padding,background-color] duration-150 ease-in-out",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -176,7 +193,7 @@ export function ModelSelector({
|
|||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
{/* show/hide visible label based on measured width */}
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<span className="min-w-0 truncate text-xs">
|
<span className="text-xs truncate">
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.modelId
|
? selectedModel.modelId
|
||||||
: dict.modelConfig.default}
|
: dict.modelConfig.default}
|
||||||
@@ -197,247 +214,199 @@ export function ModelSelector({
|
|||||||
<ModelSelectorInput
|
<ModelSelectorInput
|
||||||
placeholder={dict.modelConfig.searchModels}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<ModelSelectorEmpty>
|
||||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
{displayModels.length === 0 && models.length > 0
|
||||||
<ModelSelectorEmpty>
|
? dict.modelConfig.noVerifiedModels
|
||||||
{displayModels.length === 0 &&
|
: dict.modelConfig.noModelsFound}
|
||||||
models.length > 0
|
</ModelSelectorEmpty>
|
||||||
? dict.modelConfig.noVerifiedModels
|
|
||||||
: dict.modelConfig.noModelsFound}
|
|
||||||
</ModelSelectorEmpty>
|
|
||||||
|
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
{/* Server Default Option - only show when no server models are configured */}
|
||||||
{serverModels.length === 0 && (
|
{serverModels.length === 0 && (
|
||||||
<ModelSelectorGroup
|
<ModelSelectorGroup
|
||||||
heading={dict.modelConfig.default}
|
heading={dict.modelConfig.default}
|
||||||
>
|
>
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
value="__server_default__"
|
value="__server_default__"
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
!selectedModelId && "bg-accent",
|
!selectedModelId && "bg-accent",
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.serverDefault}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server 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"
|
||||||
>
|
>
|
||||||
<Check
|
{providerModels.map((model) => (
|
||||||
className={cn(
|
<ModelSelectorItem
|
||||||
"mr-2 h-4 w-4",
|
key={model.id}
|
||||||
!selectedModelId
|
value={model.modelId}
|
||||||
? "opacity-100"
|
onSelect={() =>
|
||||||
: "opacity-0",
|
handleSelect(model.id)
|
||||||
)}
|
}
|
||||||
/>
|
className="cursor-pointer"
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
>
|
||||||
<ModelSelectorName>
|
<Check
|
||||||
{dict.modelConfig.serverDefault}
|
className={cn(
|
||||||
</ModelSelectorName>
|
"mr-2 h-4 w-4",
|
||||||
</ModelSelectorItem>
|
selectedModelId ===
|
||||||
</ModelSelectorGroup>
|
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>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Server Models Section */}
|
{/* User Models Section */}
|
||||||
|
{userModels.length > 0 && (
|
||||||
|
<>
|
||||||
{serverModels.length > 0 && (
|
{serverModels.length > 0 && (
|
||||||
<>
|
<ModelSelectorSeparator />
|
||||||
<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>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<ModelSelectorSectionHeader
|
||||||
|
icon={<User />}
|
||||||
|
label={dict.modelConfig.userModels}
|
||||||
|
/>
|
||||||
|
{Array.from(groupedUserModels.entries()).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{ provider, models: providerModels },
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`user-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map((model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.modelId}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(model.id)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] || provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{model.modelId}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !==
|
||||||
|
true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
))}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Models Section */}
|
{/* Configure Option */}
|
||||||
{userModels.length > 0 && (
|
<ModelSelectorSeparator />
|
||||||
<>
|
<ModelSelectorGroup>
|
||||||
{serverModels.length > 0 && (
|
<ModelSelectorItem
|
||||||
<ModelSelectorSeparator />
|
value="__configure__"
|
||||||
)}
|
onSelect={handleSelect}
|
||||||
<ModelSelectorSectionHeader
|
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
icon={<User />}
|
>
|
||||||
label={dict.modelConfig.userModels}
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
/>
|
<ModelSelectorName>
|
||||||
{Array.from(
|
{dict.modelConfig.configureModels}
|
||||||
groupedUserModels.entries(),
|
</ModelSelectorName>
|
||||||
).map(
|
</ModelSelectorItem>
|
||||||
([
|
</ModelSelectorGroup>
|
||||||
providerLabel,
|
{/* Info text */}
|
||||||
{
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
provider,
|
{showUnvalidatedModels
|
||||||
models: providerModels,
|
? dict.modelConfig.allModelsShown
|
||||||
},
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`user-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map(
|
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
keywords={[
|
|
||||||
model.modelId,
|
|
||||||
providerLabel,
|
|
||||||
]}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(
|
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModelSelectorList>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
</ModelSelectorList>
|
||||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
|
||||||
{onConfigure && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__configure_models__"
|
|
||||||
onSelect={() => {
|
|
||||||
onConfigure()
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.configureModels}
|
|
||||||
</ModelSelectorName>
|
|
||||||
</ModelSelectorItem>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
|
||||||
{showUnvalidatedModels
|
|
||||||
? dict.modelConfig.allModelsShown
|
|
||||||
: dict.modelConfig.onlyVerifiedShown}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
export const AWS_REGIONS: Array<[string, string]> = [
|
|
||||||
["us-east-1", "N. Virginia"],
|
|
||||||
["us-east-2", "Ohio"],
|
|
||||||
["us-west-2", "Oregon"],
|
|
||||||
["eu-west-1", "Ireland"],
|
|
||||||
["eu-west-2", "London"],
|
|
||||||
["eu-west-3", "Paris"],
|
|
||||||
["eu-central-1", "Frankfurt"],
|
|
||||||
["ap-south-1", "Mumbai"],
|
|
||||||
["ap-northeast-1", "Tokyo"],
|
|
||||||
["ap-northeast-2", "Seoul"],
|
|
||||||
["ap-southeast-1", "Singapore"],
|
|
||||||
["ap-southeast-2", "Sydney"],
|
|
||||||
["sa-east-1", "São Paulo"],
|
|
||||||
]
|
|
||||||
|
|
||||||
interface ProviderCredentialsFieldsProps {
|
|
||||||
provider: ProviderName
|
|
||||||
// Plain (non-secret) field values — secrets are owned by renderSecret
|
|
||||||
name?: string
|
|
||||||
baseUrl?: string
|
|
||||||
awsRegion?: string
|
|
||||||
disabled?: boolean
|
|
||||||
// Update a plain text field
|
|
||||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
|
||||||
// Render the control for a secret field. The caller may include trailing
|
|
||||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
|
||||||
// shared component only supplies the label above it.
|
|
||||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
|
||||||
// Extra content after the fields — used for the Bedrock test row and the
|
|
||||||
// EdgeOne test button, which aren't beside a credential input.
|
|
||||||
footer?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display name + per-provider credential inputs, shared by the user
|
|
||||||
// ModelConfigDialog and the admin Models panel.
|
|
||||||
export function ProviderCredentialsFields({
|
|
||||||
provider,
|
|
||||||
name,
|
|
||||||
baseUrl,
|
|
||||||
awsRegion,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
renderSecret,
|
|
||||||
footer,
|
|
||||||
}: ProviderCredentialsFieldsProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const info = PROVIDER_INFO[provider]
|
|
||||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
|
||||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
|
||||||
})
|
|
||||||
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
|
||||||
|
|
||||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
|
||||||
if (provider === "edgeone") {
|
|
||||||
return <div className="space-y-5">{footer}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Display Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="provider-name"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.displayName}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="provider-name"
|
|
||||||
value={name ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onChange("name", e.target.value)}
|
|
||||||
placeholder={info.label}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{provider === "bedrock" ? (
|
|
||||||
<>
|
|
||||||
{/* AWS Access Key ID */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-access-key-id"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsAccessKeyId}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "awsAccessKeyId",
|
|
||||||
id: "aws-access-key-id",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AWS Secret Access Key */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-secret-access-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsSecretAccessKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "awsSecretAccessKey",
|
|
||||||
id: "aws-secret-access-key",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AWS Region */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-region"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsRegion}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={awsRegion || ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => onChange("awsRegion", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="aws-region"
|
|
||||||
className="h-9 font-mono text-xs hover:bg-accent"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={dict.modelConfig.selectRegion}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-64">
|
|
||||||
{AWS_REGIONS.map(([region, label]) => (
|
|
||||||
<SelectItem key={region} value={region}>
|
|
||||||
{region} ({label})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : provider === "vertexai" ? (
|
|
||||||
<>
|
|
||||||
{/* Vertex AI API Key (Express Mode) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="vertex-api-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.apiKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "vertexApiKey",
|
|
||||||
id: "vertex-api-key",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL (optional) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="vertex-base-url"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{baseUrlLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="vertex-base-url"
|
|
||||||
value={baseUrl ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("baseUrl", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={dict.modelConfig.customEndpoint}
|
|
||||||
className="h-9 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* API Key, with a link to where the provider issues keys */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label
|
|
||||||
htmlFor="api-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.apiKey}
|
|
||||||
{provider === "ollama" &&
|
|
||||||
` ${dict.modelConfig.optional}`}
|
|
||||||
</Label>
|
|
||||||
{info.apiKeyUrl && (
|
|
||||||
<a
|
|
||||||
href={info.apiKeyUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{dict.modelConfig.getApiKey}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="base-url"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{baseUrlLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="base-url"
|
|
||||||
value={baseUrl ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("baseUrl", e.target.value)
|
|
||||||
}
|
|
||||||
// Drop a pasted endpoint path such as /chat/completions
|
|
||||||
onBlur={(e) => {
|
|
||||||
const normalized = normalizeBaseUrl(
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
if (normalized !== e.target.value) {
|
|
||||||
onChange("baseUrl", normalized)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
info.defaultBaseUrl ||
|
|
||||||
dict.modelConfig.customEndpoint
|
|
||||||
}
|
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
|
||||||
/>
|
|
||||||
{requestUrl && (
|
|
||||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
|
||||||
{formatMessage(dict.modelConfig.requestUrl, {
|
|
||||||
url: requestUrl,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{provider === "minimax" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.minimaxBaseUrlHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{provider === "mimo" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.mimoBaseUrlHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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) {
|
}: QuotaLimitToastProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isTokenLimit = type === "token"
|
const isTokenLimit = type === "token"
|
||||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
|
||||||
const formatNumber = (n: number) =>
|
const formatNumber = (n: number) =>
|
||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||||
|
|
||||||
const quotaMessage = isTokenLimit
|
|
||||||
? isSelfHosted
|
|
||||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
|
||||||
: dict.quota.messageToken
|
|
||||||
: isSelfHosted
|
|
||||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
|
||||||
: dict.quota.messageApi
|
|
||||||
|
|
||||||
const tipHtml = isSelfHosted
|
|
||||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
|
||||||
: dict.quota.tip
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -84,24 +71,19 @@ export function QuotaLimitToast({
|
|||||||
</div>
|
</div>
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||||
<p>{quotaMessage}</p>
|
<p>
|
||||||
{!isSelfHosted && (
|
{isTokenLimit
|
||||||
<p
|
? dict.quota.messageToken
|
||||||
dangerouslySetInnerHTML={{
|
: dict.quota.messageApi}
|
||||||
__html: formatMessage(
|
</p>
|
||||||
dict.quota.doubaoSponsorship,
|
|
||||||
{
|
|
||||||
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p
|
<p
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: 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>
|
<p>{dict.quota.reset}</p>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
@@ -119,28 +101,24 @@ export function QuotaLimitToast({
|
|||||||
{dict.quota.configModel}
|
{dict.quota.configModel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isSelfHosted && (
|
<a
|
||||||
<>
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
<a
|
target="_blank"
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
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"
|
||||||
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}
|
||||||
<FaGithub className="w-3.5 h-3.5" />
|
</a>
|
||||||
{dict.quota.selfHost}
|
<a
|
||||||
</a>
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
<a
|
target="_blank"
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
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"
|
||||||
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}
|
||||||
<Coffee className="w-3.5 h-3.5" />
|
</a>
|
||||||
{dict.quota.sponsor}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
export type ExportFormat = "drawio" | "png" | "svg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,11 +74,6 @@ export function SaveDialog({
|
|||||||
label: dict.save.formats.svg,
|
label: dict.save.formats.svg,
|
||||||
extension: ".svg",
|
extension: ".svg",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "xmlsvg" as const,
|
|
||||||
label: dict.save.formats.xmlsvg,
|
|
||||||
extension: ".drawio.svg",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||||
|
|||||||
+27
-207
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"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 { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +22,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
|
||||||
@@ -58,25 +56,19 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
en: "English",
|
en: "English",
|
||||||
zh: "中文",
|
zh: "中文",
|
||||||
ja: "日本語",
|
ja: "日本語",
|
||||||
"zh-Hant": "繁體中文",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
drawioUi: DrawioTheme
|
drawioUi: "min" | "sketch"
|
||||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
onToggleDrawioUi: () => void
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
vlmValidationEnabled?: boolean
|
vlmValidationEnabled?: boolean
|
||||||
onVlmValidationChange?: (value: boolean) => void
|
onVlmValidationChange?: (value: boolean) => void
|
||||||
onOpenModelConfig?: () => void
|
|
||||||
customSystemMessage?: string
|
|
||||||
onCustomSystemMessageChange?: (value: string) => void
|
|
||||||
maxOutputTokens?: string
|
|
||||||
onMaxOutputTokensChange?: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
@@ -93,18 +85,13 @@ function SettingsContent({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onDrawioUiChange,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
vlmValidationEnabled = false,
|
vlmValidationEnabled = false,
|
||||||
onVlmValidationChange = () => {},
|
onVlmValidationChange = () => {},
|
||||||
onOpenModelConfig,
|
|
||||||
customSystemMessage = "",
|
|
||||||
onCustomSystemMessageChange = () => {},
|
|
||||||
maxOutputTokens = "",
|
|
||||||
onMaxOutputTokensChange = () => {},
|
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -119,31 +106,14 @@ function SettingsContent({
|
|||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
|
|
||||||
// Panel visibility state
|
|
||||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
|
||||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
|
||||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
|
||||||
|
|
||||||
const handlePanelToggle = useCallback(
|
|
||||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
|
||||||
setter(value)
|
|
||||||
localStorage.setItem(key, String(value))
|
|
||||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Proxy settings state (Electron only)
|
// Proxy settings state (Electron only)
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
const [httpProxy, setHttpProxy] = useState("")
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
const [httpsProxy, setHttpsProxy] = useState("")
|
||||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
// Only fetch if not cached in localStorage
|
||||||
// the access code input if the server requires it. This fixes the case
|
if (getStoredAccessCodeRequired() !== null) return
|
||||||
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
|
||||||
// configured) would hide the access code input.
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch(getApiEndpoint("/api/config"))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -159,9 +129,10 @@ function SettingsContent({
|
|||||||
setAccessCodeRequired(required)
|
setAccessCodeRequired(required)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Keep existing cached value on error
|
// Don't cache on error - allow retry on next mount
|
||||||
|
setAccessCodeRequired(false)
|
||||||
})
|
})
|
||||||
}, [open])
|
}, [])
|
||||||
|
|
||||||
// Detect current language from pathname
|
// Detect current language from pathname
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,17 +156,6 @@ function SettingsContent({
|
|||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||||
|
|
||||||
setShowRecentChats(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
|
||||||
)
|
|
||||||
setShowMyTemplates(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
|
||||||
)
|
|
||||||
setShowQuickExamples(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
|
||||||
"false",
|
|
||||||
)
|
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
// Load proxy settings (Electron only)
|
||||||
@@ -311,7 +271,7 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
@@ -321,29 +281,8 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
<div className="px-6 pb-6">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{/* API Keys & Models */}
|
|
||||||
{onOpenModelConfig && (
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.apiKeysModels}
|
|
||||||
description={dict.settings.apiKeysModelsDescription}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenChange(false)
|
|
||||||
onOpenModelConfig()
|
|
||||||
}}
|
|
||||||
aria-label={dict.settings.apiKeysModels}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Access Code (conditional) */}
|
{/* Access Code (conditional) */}
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<div className="py-4 first:pt-0 space-y-3">
|
<div className="py-4 first:pt-0 space-y-3">
|
||||||
@@ -437,40 +376,23 @@ function SettingsContent({
|
|||||||
{/* Draw.io Style */}
|
{/* Draw.io Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.drawioStyle}
|
label={dict.settings.drawioStyle}
|
||||||
description={dict.settings.drawioStyleDescription}
|
description={`${dict.settings.drawioStyleDescription} ${
|
||||||
|
drawioUi === "min"
|
||||||
|
? dict.settings.minimal
|
||||||
|
: dict.settings.sketch
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Select
|
<Button
|
||||||
value={drawioUi}
|
id="drawio-ui"
|
||||||
onValueChange={(v) =>
|
variant="outline"
|
||||||
onDrawioUiChange(v as DrawioTheme)
|
onClick={onToggleDrawioUi}
|
||||||
}
|
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
{dict.settings.switchTo}{" "}
|
||||||
id="drawio-ui-select"
|
{drawioUi === "min"
|
||||||
aria-label={dict.settings.drawioStyle}
|
? dict.settings.sketch
|
||||||
className="w-[120px] h-9 rounded-xl"
|
: dict.settings.minimal}
|
||||||
>
|
</Button>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="kennedy">
|
|
||||||
{dict.settings.themeDefault}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="atlas">Atlas</SelectItem>
|
|
||||||
<SelectItem value="dark">
|
|
||||||
{dict.settings.themeDark}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="min">
|
|
||||||
{dict.settings.themeMinimal}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="sketch">
|
|
||||||
{dict.settings.themeSketch}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="simple">
|
|
||||||
{dict.settings.themeSimple}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
@@ -492,63 +414,6 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Panel Visibility */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.panelVisibility}
|
|
||||||
description={dict.settings.panelVisibilityDescription}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-recent-chats"
|
|
||||||
checked={showRecentChats}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showRecentChats,
|
|
||||||
v,
|
|
||||||
setShowRecentChats,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showRecentChats}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-my-templates"
|
|
||||||
checked={showMyTemplates}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showMyTemplates,
|
|
||||||
v,
|
|
||||||
setShowMyTemplates,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showMyTemplates}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Switch
|
|
||||||
id="show-quick-examples"
|
|
||||||
checked={showQuickExamples}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
handlePanelToggle(
|
|
||||||
STORAGE_KEYS.showQuickExamples,
|
|
||||||
v,
|
|
||||||
setShowQuickExamples,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.showQuickExamples}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* VLM Diagram Validation */}
|
{/* VLM Diagram Validation */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramValidation}
|
label={dict.settings.diagramValidation}
|
||||||
@@ -568,51 +433,6 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Custom System Message */}
|
|
||||||
<div className="py-4 space-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="custom-system-message"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{dict.settings.customSystemMessage}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.customSystemMessageDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id="custom-system-message"
|
|
||||||
value={customSystemMessage}
|
|
||||||
onChange={(e) =>
|
|
||||||
onCustomSystemMessageChange(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
dict.settings.customSystemMessagePlaceholder
|
|
||||||
}
|
|
||||||
className="min-h-[80px] max-h-[160px] text-sm"
|
|
||||||
maxLength={5000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Output Tokens */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.maxOutputTokens}
|
|
||||||
description={dict.settings.maxOutputTokensDescription}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="max-output-tokens"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={maxOutputTokens}
|
|
||||||
onChange={(e) =>
|
|
||||||
onMaxOutputTokensChange(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="64000"
|
|
||||||
className="h-9 w-28 text-sm"
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
{/* Send Shortcut */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.sendShortcut}
|
label={dict.settings.sendShortcut}
|
||||||
|
|||||||
@@ -77,13 +77,12 @@ function CommandInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
function CommandList({
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
...props
|
...props
|
||||||
|
|||||||
+197
-211
@@ -1,36 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
BLANK_MXFILE,
|
extractDiagramXML,
|
||||||
normalizeToMxfile,
|
isRealDiagram,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
validateAndFixXml,
|
||||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
} from "../lib/utils"
|
||||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
|
||||||
chartXMLRef: React.MutableRefObject<string>
|
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
// Both return the export's tag (empty when draw.io is not there yet)
|
handleExport: () => void
|
||||||
handleExport: () => string
|
handleExportWithoutHistory: () => void
|
||||||
handleExportWithoutHistory: () => string
|
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||||
// Pending exports by tag; a history or plain export's resolver gets the
|
|
||||||
// first page's XML
|
|
||||||
exportResolversRef: React.MutableRefObject<
|
|
||||||
Record<string, (data: string, xml?: string) => void>
|
|
||||||
>
|
|
||||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: EventExport) => void
|
handleDiagramExport: (data: any) => void
|
||||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
@@ -50,12 +42,6 @@ interface DiagramContextType {
|
|||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||||
|
|
||||||
// Every export carries a tag in the request's `message` field. draw.io
|
|
||||||
// echoes the request back in the export event, so each result reaches its
|
|
||||||
// own caller. Tags end in a request number, so a late result never answers
|
|
||||||
// a newer request.
|
|
||||||
type ExportTag = "thumbnail" | "validation"
|
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("")
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||||
@@ -66,13 +52,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
// Pending exports, keyed by their export tag
|
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
const exportResolversRef = useRef<
|
// Resolver for PNG export (used for VLM validation)
|
||||||
Record<string, (data: string, xml?: string) => void>
|
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
>({})
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
// Pending history exports: the document each one was asked for
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
const historyXmlRef = useRef(new Map<string, string>())
|
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
||||||
const exportSeqRef = useRef(0)
|
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
const chartXMLRef = useRef<string>("")
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
@@ -81,10 +67,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
setIsDrawioReady(true)
|
setIsDrawioReady(true)
|
||||||
// Restore diagram after remount (e.g., theme/UI change)
|
|
||||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
|
||||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDrawioReady = () => {
|
const resetDrawioReady = () => {
|
||||||
@@ -92,85 +74,112 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chartXML and its ref together, so callbacks that read the ref
|
// Keep chartXMLRef in sync with state for restoration after remount
|
||||||
// (export handler, autosave) see the new value right away
|
useEffect(() => {
|
||||||
const updateChartXML = (xml: string) => {
|
chartXMLRef.current = chartXML
|
||||||
chartXMLRef.current = xml
|
}, [chartXML])
|
||||||
setChartXML(xml)
|
|
||||||
}
|
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
||||||
|
if (!isDrawioReady) {
|
||||||
|
hasDiagramRestoredRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only restore once per ready cycle
|
||||||
|
if (hasDiagramRestoredRef.current) return
|
||||||
|
hasDiagramRestoredRef.current = true
|
||||||
|
|
||||||
|
// Restore diagram from ref if we have one
|
||||||
|
const xmlToRestore = chartXMLRef.current
|
||||||
|
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
||||||
|
drawioRef.current.load({ xml: xmlToRestore })
|
||||||
|
}
|
||||||
|
}, [isDrawioReady])
|
||||||
|
|
||||||
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
|
const saveResolverRef = useRef<{
|
||||||
|
resolver: ((data: string) => void) | null
|
||||||
|
format: ExportFormat | null
|
||||||
|
}>({ resolver: null, format: null })
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (!drawioRef.current) return ""
|
if (drawioRef.current) {
|
||||||
// Save this export to history, with the document shown now:
|
// Mark that this export should be saved to history
|
||||||
// chartXML can change before the result comes back
|
expectHistoryExportRef.current = true
|
||||||
const tag = `history-${++exportSeqRef.current}`
|
drawioRef.current.exportDiagram({
|
||||||
historyXmlRef.current.set(tag, chartXMLRef.current)
|
format: "xmlsvg",
|
||||||
drawioRef.current.exportDiagram({
|
})
|
||||||
format: "xmlsvg",
|
}
|
||||||
message: tag,
|
|
||||||
})
|
|
||||||
return tag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportWithoutHistory = () => {
|
const handleExportWithoutHistory = () => {
|
||||||
if (!drawioRef.current) return ""
|
if (drawioRef.current) {
|
||||||
// Export without saving to history (for edit_diagram fetching current state)
|
// Export without saving to history (for edit_diagram fetching current state)
|
||||||
const tag = `fetch-${++exportSeqRef.current}`
|
drawioRef.current.exportDiagram({
|
||||||
drawioRef.current.exportDiagram({
|
format: "xmlsvg",
|
||||||
format: "xmlsvg",
|
})
|
||||||
message: tag,
|
}
|
||||||
})
|
|
||||||
return tag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||||
// event) and wait for that result. Resolves to null on timeout, which is
|
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||||
// expected occasionally.
|
|
||||||
// (Reads refs only, so it keeps one identity)
|
|
||||||
const requestTaggedExport = useCallback(
|
|
||||||
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
|
|
||||||
new Promise<string | null>((resolve) => {
|
|
||||||
const id = `${tag}-${++exportSeqRef.current}`
|
|
||||||
const finish = (value: string | null) => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
delete exportResolversRef.current[id]
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
|
||||||
exportResolversRef.current[id] = finish
|
|
||||||
drawioRef.current?.exportDiagram({ format, message: id })
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
|
||||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
|
||||||
// renders this provider again (latestSvg), which would otherwise start
|
|
||||||
// the next save
|
|
||||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXML)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svgData = await Promise.race([
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolverRef.current = resolve
|
||||||
|
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||||
|
}),
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
// xmlsvg exports return an SVG data URL
|
|
||||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
|
||||||
if (svgData?.startsWith("data:image/svg")) {
|
|
||||||
// Update latestSvg so it's available for future saves
|
// Update latestSvg so it's available for future saves
|
||||||
setLatestSvg(svgData)
|
if (svgData?.includes("<svg")) {
|
||||||
return svgData
|
setLatestSvg(svgData)
|
||||||
|
return svgData
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
// Timeout is expected occasionally - don't log as error
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return null
|
}
|
||||||
}, [requestTaggedExport])
|
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
// Capture current diagram as PNG for VLM validation
|
||||||
const captureValidationPng = async (): Promise<string | null> => {
|
const captureValidationPng = async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXML)) return null
|
||||||
|
|
||||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
try {
|
||||||
// PNG data should be a base64 data URL
|
const pngData = await Promise.race([
|
||||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
new Promise<string>((resolve) => {
|
||||||
|
pngResolverRef.current = resolve
|
||||||
|
drawioRef.current?.exportDiagram({ format: "png" })
|
||||||
|
}),
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error("PNG export timeout")),
|
||||||
|
5000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
// PNG data should be a base64 data URL
|
||||||
|
if (pngData?.startsWith("data:image/png")) {
|
||||||
|
return pngData
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
// Timeout is expected occasionally - don't log as error
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (
|
||||||
@@ -179,11 +188,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
): string | null => {
|
): string | null => {
|
||||||
let xmlToLoad = chart
|
let xmlToLoad = chart
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal
|
// Validate XML structure before loading (unless skipped for internal use)
|
||||||
// use). Not strict: the XML may hold the user's own diagram, and the
|
|
||||||
// tool handlers check model XML strictly before it gets here.
|
|
||||||
if (!skipValidation) {
|
if (!skipValidation) {
|
||||||
const validation = validateAndFixXml(chart, { strict: false })
|
const validation = validateAndFixXml(chart)
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[loadDiagram] Validation error:",
|
"[loadDiagram] Validation error:",
|
||||||
@@ -202,7 +209,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||||
updateChartXML(xmlToLoad)
|
setChartXML(xmlToLoad)
|
||||||
|
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
drawioRef.current.load({
|
drawioRef.current.load({
|
||||||
@@ -213,68 +220,61 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: EventExport) => {
|
const handleDiagramExport = (data: any) => {
|
||||||
// Thumbnail, validation PNG and file save exports go only to their
|
// Handle PNG export for VLM validation
|
||||||
// own caller
|
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||||
const tag = data.message?.message
|
pngResolverRef.current(data.data)
|
||||||
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
|
pngResolverRef.current = null
|
||||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't write chartXML here: exports don't change the diagram, and
|
// Handle save to file if requested (process raw data before extraction)
|
||||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
if (saveResolverRef.current.resolver) {
|
||||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
const format = saveResolverRef.current.format
|
||||||
// up to date with the full uncompressed multi-page document (#879).
|
saveResolverRef.current.resolver(data.data)
|
||||||
|
saveResolverRef.current = { resolver: null, format: null }
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extractedXML = extractDiagramXML(data.data)
|
const extractedXML = extractDiagramXML(data.data)
|
||||||
|
setChartXML(extractedXML)
|
||||||
setLatestSvg(data.data)
|
setLatestSvg(data.data)
|
||||||
|
|
||||||
// Only add to history if this was a user-initiated export
|
// Only add to history if this was a user-initiated export
|
||||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||||
const MAX_HISTORY_SIZE = 20
|
const MAX_HISTORY_SIZE = 20
|
||||||
const askedXml =
|
if (expectHistoryExportRef.current) {
|
||||||
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
|
|
||||||
if (askedXml !== undefined) {
|
|
||||||
historyXmlRef.current.delete(tag as string)
|
|
||||||
// Store the full multi-page document (extractedXML is only the
|
|
||||||
// first page), so restoring a version keeps every page
|
|
||||||
const historyXml = askedXml || extractedXML
|
|
||||||
setDiagramHistory((prev) => {
|
setDiagramHistory((prev) => {
|
||||||
const newHistory = [
|
const newHistory = [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
svg: data.data,
|
svg: data.data,
|
||||||
xml: historyXml,
|
xml: extractedXML,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||||
})
|
})
|
||||||
|
expectHistoryExportRef.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chat's own export (onFetchChart), not another one in flight
|
if (resolverRef.current) {
|
||||||
const resolve =
|
resolverRef.current(extractedXML)
|
||||||
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
resolverRef.current = null
|
||||||
if (resolve) {
|
|
||||||
delete exportResolversRef.current[tag as string]
|
|
||||||
resolve(extractedXML)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// react-drawio registers this callback once per iframe mount, so it must
|
|
||||||
// read refs: state captured in its closure would stay stale after a remount
|
|
||||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
if (!data?.xml) return
|
if (!data?.xml) return
|
||||||
// Don't overwrite a pending restore - if we have a real diagram but
|
setChartXML(data.xml)
|
||||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
|
||||||
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateChartXML(data.xml)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = 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)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
loadDiagram(emptyDiagram, true)
|
loadDiagram(emptyDiagram, true)
|
||||||
setLatestSvg("")
|
setLatestSvg("")
|
||||||
@@ -293,91 +293,78 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat =
|
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
|
||||||
|
|
||||||
// Each save has its own tag, so two at once never swap results
|
// Set up the resolver before triggering export
|
||||||
const tag = `save-${++exportSeqRef.current}`
|
saveResolverRef.current = {
|
||||||
exportResolversRef.current[tag] = (
|
resolver: (exportData: string) => {
|
||||||
exportData: string,
|
let fileContent: string | Blob
|
||||||
fullDiagramXML?: string,
|
let mimeType: string
|
||||||
) => {
|
let extension: string
|
||||||
delete exportResolversRef.current[tag]
|
|
||||||
let fileContent: string | Blob
|
|
||||||
let mimeType: string
|
|
||||||
let extension: string
|
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Prefer the complete document from the export event so all pages are saved.
|
// Extract XML from SVG for .drawio format
|
||||||
const xml = fullDiagramXML?.trim()
|
const xml = extractDiagramXML(exportData)
|
||||||
? fullDiagramXML
|
let xmlContent = xml
|
||||||
: extractDiagramXML(exportData)
|
if (!xml.includes("<mxfile")) {
|
||||||
fileContent =
|
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||||
normalizeToMxfile(xml, {
|
}
|
||||||
pageId: "page-1",
|
fileContent = xmlContent
|
||||||
pageName: "Page-1",
|
mimeType = "application/xml"
|
||||||
}) ?? xml
|
extension = ".drawio"
|
||||||
mimeType = "application/xml"
|
} else if (format === "png") {
|
||||||
extension = ".drawio"
|
// PNG data comes as base64 data URL
|
||||||
} else if (format === "png") {
|
fileContent = exportData
|
||||||
// PNG data comes as base64 data URL
|
mimeType = "image/png"
|
||||||
fileContent = exportData
|
extension = ".png"
|
||||||
mimeType = "image/png"
|
} else {
|
||||||
extension = ".png"
|
// SVG format
|
||||||
} else if (format === "xmlsvg") {
|
fileContent = exportData
|
||||||
// Editable SVG: pass data URL directly (like PNG)
|
mimeType = "image/svg+xml"
|
||||||
fileContent = exportData
|
extension = ".svg"
|
||||||
mimeType = "image/svg+xml"
|
}
|
||||||
extension = ".drawio.svg"
|
|
||||||
} else {
|
|
||||||
// SVG format (view-only)
|
|
||||||
fileContent = exportData
|
|
||||||
mimeType = "image/svg+xml"
|
|
||||||
extension = ".svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
// Log save event to Langfuse (flags the trace)
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
logSaveToLangfuse(filename, format, sessionId)
|
||||||
|
|
||||||
// Handle download
|
// Handle download
|
||||||
let url: string
|
let url: string
|
||||||
if (
|
if (
|
||||||
typeof fileContent === "string" &&
|
typeof fileContent === "string" &&
|
||||||
fileContent.startsWith("data:")
|
fileContent.startsWith("data:")
|
||||||
) {
|
) {
|
||||||
// Already a data URL (PNG)
|
// Already a data URL (PNG)
|
||||||
url = fileContent
|
url = fileContent
|
||||||
} else {
|
} else {
|
||||||
const blob = new Blob([fileContent], { type: mimeType })
|
const blob = new Blob([fileContent], { type: mimeType })
|
||||||
url = URL.createObjectURL(blob)
|
url = URL.createObjectURL(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${filename}${extension}`
|
a.download = `${filename}${extension}`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
// Show success toast after download is initiated
|
// Show success toast after download is initiated
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
toast.success(successMessage, {
|
toast.success(successMessage, {
|
||||||
position: "bottom-left",
|
position: "bottom-left",
|
||||||
duration: 2500,
|
duration: 2500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
format,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// Export diagram - callback will be handled in handleDiagramExport
|
||||||
drawioRef.current.exportDiagram({
|
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||||
format: drawioFormat,
|
|
||||||
message: tag,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||||
@@ -401,14 +388,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef,
|
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
setDiagramHistory,
|
setDiagramHistory,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
exportResolversRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
|
||||||
# Persists admin panel settings (data/settings.json)
|
|
||||||
- ./data:/app/data
|
|
||||||
# environment:
|
# environment:
|
||||||
# # For subdirectory deployment, uncomment and set your path:
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
|
|||||||
+19
-33
@@ -19,9 +19,7 @@
|
|||||||
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||||
|
|
||||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://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 模型!
|
> 注:感谢 <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 模型!
|
||||||
|
|
||||||
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
@@ -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)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
@@ -56,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>动画Transformer连接器</strong><br />
|
<strong>动画Transformer连接器</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG技术图</strong><br />
|
<strong>GCP架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>React和AWS认证流程</strong><br />
|
<strong>AWS架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>开放式创新</strong><br />
|
<strong>Azure架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -98,7 +96,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
## MCP服务器
|
## MCP服务器(预览)
|
||||||
|
|
||||||
|
> **预览功能**:此功能为实验性功能,可能不稳定。
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||||
|
|
||||||
@@ -124,13 +124,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等客户端配置。
|
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -202,7 +195,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)
|
- [字节跳动豆包](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(默认)
|
- AWS Bedrock(默认)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -211,7 +204,6 @@ npm run dev
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -224,18 +216,12 @@ npm run dev
|
|||||||
|
|
||||||
### 服务端多模型配置
|
### 服务端多模型配置
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
|
|
||||||
### 管理面板
|
|
||||||
|
|
||||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
|
||||||
|
|
||||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
@@ -250,7 +236,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)来帮助我托管在线演示站点!
|
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||||
|
|
||||||
|
|||||||
@@ -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_*` 变量在构建时固化,无法在面板中修改。
|
|
||||||
+4
-133
@@ -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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -46,23 +46,6 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -70,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
可选的自定义端点:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -151,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=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 角色获取。
|
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -194,7 +168,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|||||||
可选的自定义 URL:
|
可选的自定义 URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
@@ -233,98 +207,6 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax 支持两种 API 格式:
|
|
||||||
- **Anthropic 兼容**(`/anthropic` 端点)— 推荐,支持 interleaved thinking
|
|
||||||
- **OpenAI 兼容**(`/v1` 端点)— 标准 OpenAI 聊天补全格式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
可选配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 中国大陆版,Anthropic 兼容(默认)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# 中国大陆版,OpenAI 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# 国际版,Anthropic 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# 国际版,OpenAI 兼容
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (智谱 AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (阿里云通义千问)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (月之暗面 Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (七牛云)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (小米)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -332,7 +214,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端多模型配置
|
## 服务端多模型配置
|
||||||
@@ -353,17 +235,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
在项目根目录创建 `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
|
```json
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+4
-133
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -61,23 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
LM Studio and other local OpenAI-compatible servers work the same way: set the base URL to `http://localhost:1234/v1` (LM Studio's default port) and use any non-empty API key, such as `lm-studio`. This works both in the environment variables above and in the model settings.
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -85,13 +68,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
Optional custom endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -166,11 +142,9 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=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.
|
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -196,7 +170,7 @@ AI_MODEL=llama3.2
|
|||||||
Optional custom URL:
|
Optional custom URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
### ModelScope
|
||||||
@@ -248,98 +222,6 @@ Model format uses `provider/model` syntax:
|
|||||||
|
|
||||||
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax supports two API formats:
|
|
||||||
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
|
|
||||||
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# China mainland, Anthropic-compatible (default)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# China mainland, OpenAI-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# International, Anthropic-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# International, OpenAI-compatible
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (Zhipu AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (Alibaba Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (Qiniu Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection
|
## Auto-Detection
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
@@ -347,7 +229,7 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, 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, modelscope
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
## Server-Side Multi-Model Configuration
|
||||||
@@ -368,17 +250,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
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
|
### Example Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+19
-31
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||||
|
|
||||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://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
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目次](#目次)
|
- [目次](#目次)
|
||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー](#mcpサーバー)
|
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG技術ダイアグラム</strong><br />
|
<strong>GCPアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>ReactとAWSによる認証</strong><br />
|
<strong>AWSアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>オープンイノベーション</strong><br />
|
<strong>Azureアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,7 +96,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
## MCPサーバー
|
## MCPサーバー(プレビュー)
|
||||||
|
|
||||||
|
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||||
|
|
||||||
@@ -122,13 +124,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などのクライアント設定も含む)。
|
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
@@ -201,7 +196,7 @@ 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(デフォルト)
|
- AWS Bedrock(デフォルト)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -210,7 +205,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -223,18 +217,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
### 管理パネル
|
|
||||||
|
|
||||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
|
||||||
|
|
||||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
|
|
||||||
@@ -249,7 +237,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)をご検討ください!
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||||
|
|
||||||
|
|||||||
@@ -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_*` 変数はビルド時に固定され、パネルでは変更できません。
|
|
||||||
+4
-133
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### 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
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -46,23 +46,6 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
LM Studio などのローカルな OpenAI 互換サーバーも同じ方法で使えます。ベース URL を `http://localhost:1234/v1`(LM Studio の既定ポート)に設定し、API キーには `lm-studio` など空でない任意の値を入力してください。上の環境変数でも、モデル設定画面でも設定できます。
|
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -70,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
任意のカスタムエンドポイント:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -151,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
|||||||
AWS_REGION=us-west-2
|
AWS_REGION=us-west-2
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
AI_MODEL=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 ロールから自動的に取得されます。
|
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
@@ -181,7 +155,7 @@ AI_MODEL=llama3.2
|
|||||||
任意のカスタム URL:
|
任意のカスタム URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
### ModelScope
|
||||||
@@ -233,98 +207,6 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
||||||
|
|
||||||
### MiniMax
|
|
||||||
|
|
||||||
MiniMax は 2 つの API 形式をサポートしています:
|
|
||||||
- **Anthropic 互換**(`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
|
|
||||||
- **OpenAI 互換**(`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MINIMAX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=MiniMax-M3
|
|
||||||
```
|
|
||||||
|
|
||||||
オプション設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 中国大陸版、Anthropic 互換(デフォルト)
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
|
|
||||||
# 中国大陸版、OpenAI 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
|
||||||
|
|
||||||
# 国際版、Anthropic 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
|
|
||||||
# 国際版、OpenAI 互換
|
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### GLM (Zhipu AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_API_KEY=your_api_key
|
|
||||||
AI_MODEL=glm-4
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLM_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qwen (Alibaba Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_API_KEY=your_api_key
|
|
||||||
AI_MODEL=qwen-turbo
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kimi (Moonshot AI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=kimi-latest
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KIMI_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Qiniu (Qiniu Cloud)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -332,7 +214,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
## サーバーサイドマルチモデル設定
|
||||||
@@ -353,17 +235,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
プロジェクトルートに `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
|
```json
|
||||||
|
|||||||
@@ -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) |
|
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
||||||
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
||||||
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
||||||
|
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
|
||||||
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
||||||
|
|
||||||
## Networking & Infrastructure
|
## Networking & Infrastructure
|
||||||
@@ -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) |
|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
||||||
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
||||||
|
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
|
||||||
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
||||||
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
||||||
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
||||||
@@ -28,6 +30,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
||||||
|
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
|
||||||
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
||||||
|
|
||||||
## General Diagrams
|
## General Diagrams
|
||||||
@@ -45,7 +48,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
||||||
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
|
|
||||||
|
|
||||||
## Enterprise Software
|
## Enterprise Software
|
||||||
|
|
||||||
@@ -71,5 +73,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
|||||||
| Library | Shapes | Prefix | Description | File |
|
| Library | Shapes | Prefix | Description | File |
|
||||||
|---------|--------|--------|-------------|------|
|
|---------|--------|--------|-------------|------|
|
||||||
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
||||||
|
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
|
||||||
|
|
||||||
**Total: 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,64 +67,41 @@ const MODEL_ALIASES: Record<string, string> = {
|
|||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create standardized JSON response
|
* Create standardized response with CORS headers
|
||||||
*/
|
*/
|
||||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...CORS_HEADERS,
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
/**
|
||||||
// function, so no CORS headers are sent: other sites' pages can't call it
|
* Handle OPTIONS request for CORS preflight
|
||||||
// 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.
|
function handleOptionsRequest(): Response {
|
||||||
// No codes configured (or env unavailable) means no check.
|
return new Response(null, {
|
||||||
function hasValidAccessCode(request: Request, env: any): boolean {
|
headers: {
|
||||||
const accessCodes: string[] =
|
...CORS_HEADERS,
|
||||||
env?.ACCESS_CODE_LIST?.split(",")
|
"Access-Control-Max-Age": "86400",
|
||||||
.map((code: string) => code.trim())
|
},
|
||||||
.filter(Boolean) || []
|
})
|
||||||
if (accessCodes.length === 0) return true
|
|
||||||
const accessCode = request.headers.get("x-access-code")
|
|
||||||
return !!accessCode && accessCodes.includes(accessCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onRequest({ request, env }: any) {
|
export async function onRequest({ request, env: _env }: any) {
|
||||||
// Requiring JSON also makes any cross-site browser request need a CORS
|
if (request.method === "OPTIONS") {
|
||||||
// preflight, which fails without CORS headers. Only the type before any
|
return handleOptionsRequest()
|
||||||
// parameters counts: "text/plain; x=application/json" needs none.
|
|
||||||
const mediaType = (request.headers.get("content-type") ?? "")
|
|
||||||
.split(";")[0]
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
if (request.method !== "POST" || mediaType !== "application/json") {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: "Expected a POST request with a JSON body",
|
|
||||||
type: "invalid_request_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasValidAccessCode(request, env)) {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: "Invalid or missing access code",
|
|
||||||
type: "invalid_request_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.headers.delete("accept-encoding")
|
request.headers.delete("accept-encoding")
|
||||||
@@ -176,7 +153,7 @@ export async function onRequest({ request, env }: any) {
|
|||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400,
|
429,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +216,7 @@ export async function onRequest({ request, env }: any) {
|
|||||||
"Cache-Control": "no-cache, no-store, no-transform",
|
"Cache-Control": "no-cache, no-store, no-transform",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
|
...CORS_HEADERS,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -10,13 +10,8 @@ directories:
|
|||||||
afterPack: ./scripts/afterPack.cjs
|
afterPack: ./scripts/afterPack.cjs
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- from: dist-electron
|
- dist-electron/**/*
|
||||||
to: dist-electron
|
- "!node_modules"
|
||||||
filter:
|
|
||||||
- "**/*"
|
|
||||||
- from: .
|
|
||||||
filter:
|
|
||||||
- package.json
|
|
||||||
|
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
@@ -95,10 +90,6 @@ linux:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
- target: rpm
|
|
||||||
arch:
|
|
||||||
- x64
|
|
||||||
- arm64
|
|
||||||
|
|
||||||
# Publish configuration (optional)
|
# Publish configuration (optional)
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
Vendored
+6
-13
@@ -60,25 +60,18 @@ declare global {
|
|||||||
maximize: () => void
|
maximize: () => void
|
||||||
/** Close the window */
|
/** Close the window */
|
||||||
close: () => void
|
close: () => void
|
||||||
|
/** Open file dialog and return file path */
|
||||||
|
openFile: () => Promise<string | null>
|
||||||
|
/** Save data to file via save dialog */
|
||||||
|
saveFile: (data: string) => Promise<boolean>
|
||||||
/** Get proxy configuration */
|
/** Get proxy configuration */
|
||||||
getProxy: () => Promise<ProxyConfig>
|
getProxy: () => Promise<ProxyConfig>
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||||
/** Get user's preferred locale */
|
/** Get user's preferred locale */
|
||||||
getUserLocale: () => Promise<
|
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
|
||||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
|
||||||
>
|
|
||||||
/** Set user's preferred locale */
|
/** Set user's preferred locale */
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
/**
|
|
||||||
* Call back after the server restarted on the same port (another
|
|
||||||
* preset); returns a function that stops the calls
|
|
||||||
*/
|
|
||||||
onServerRestarted?: (callback: () => void) => () => void
|
|
||||||
/** A chat was saved: open this port next launch */
|
|
||||||
chatSaved?: () => Promise<void>
|
|
||||||
/** The page loaded with this many chats */
|
|
||||||
chatsLoaded?: (count: number) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -106,8 +99,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ApplyPresetResult,
|
|
||||||
ConfigPreset,
|
ConfigPreset,
|
||||||
|
ApplyPresetResult,
|
||||||
ProxyConfig,
|
ProxyConfig,
|
||||||
SetProxyResult,
|
SetProxyResult,
|
||||||
SetUserLocaleResult,
|
SetUserLocaleResult,
|
||||||
|
|||||||
+26
-70
@@ -32,68 +32,6 @@ export function rebuildAppMenu(): void {
|
|||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Number of the latest preset switch
|
|
||||||
let lastSwitch = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a preset and restart the server so it takes effect.
|
|
||||||
* If the restart fails, go back to the previous preset and restart again,
|
|
||||||
* so the running server always matches the saved current preset.
|
|
||||||
* Throws an error describing the outcome on failure.
|
|
||||||
*/
|
|
||||||
export async function switchPreset(
|
|
||||||
id: string,
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
const previousPresetId = getCurrentPresetId()
|
|
||||||
const env = applyPresetToEnv(id)
|
|
||||||
if (!env) {
|
|
||||||
throw new Error("Preset not found")
|
|
||||||
}
|
|
||||||
const switchNumber = ++lastSwitch
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
return env
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to restart server:", error)
|
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
|
||||||
|
|
||||||
// A newer switch started meanwhile (also of this same preset), or
|
|
||||||
// the preset was deleted: its own restart follows, and undoing
|
|
||||||
// would lose that choice
|
|
||||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revert to previous preset on failure
|
|
||||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
|
||||||
setCurrentPreset(null)
|
|
||||||
}
|
|
||||||
// Rebuild menu to restore previous checkmark state
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error("Failed to restart server again:", retryError)
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template with translations
|
* Get the menu template with translations
|
||||||
*/
|
*/
|
||||||
@@ -254,14 +192,32 @@ function buildConfigMenu(
|
|||||||
type: "radio",
|
type: "radio",
|
||||||
checked: preset.id === currentPresetId,
|
checked: preset.id === currentPresetId,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
try {
|
const previousPresetId = getCurrentPresetId()
|
||||||
await switchPreset(preset.id)
|
const env = applyPresetToEnv(preset.id)
|
||||||
} catch (error) {
|
|
||||||
// Show error dialog to notify user
|
if (env) {
|
||||||
dialog.showErrorBox(
|
try {
|
||||||
"Configuration Error",
|
await restartNextServer()
|
||||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
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)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
+75
-160
@@ -1,11 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import {
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { app, safeStorage } from "electron"
|
import { app, safeStorage } from "electron"
|
||||||
|
|
||||||
@@ -36,9 +30,7 @@ let hasWarnedAboutPlaintext = false
|
|||||||
* Warns if encryption is not available (API key stored in plaintext)
|
* Warns if encryption is not available (API key stored in plaintext)
|
||||||
*/
|
*/
|
||||||
function encryptValue(value: string): string {
|
function encryptValue(value: string): string {
|
||||||
// Already encrypted (a value that could not be decrypted): keep it as is
|
if (!value) {
|
||||||
// instead of wrapping it in a second layer of encryption
|
|
||||||
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +61,6 @@ function encryptValue(value: string): string {
|
|||||||
/**
|
/**
|
||||||
* Decrypt a sensitive value using safeStorage
|
* Decrypt a sensitive value using safeStorage
|
||||||
* Returns the original value if it's not encrypted or decryption fails
|
* Returns the original value if it's not encrypted or decryption fails
|
||||||
* (so saving writes the stored ciphertext back unchanged)
|
|
||||||
*/
|
*/
|
||||||
function decryptValue(value: string): string {
|
function decryptValue(value: string): string {
|
||||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
@@ -146,7 +137,7 @@ interface ConfigPresetsFile {
|
|||||||
version: 1
|
version: 1
|
||||||
currentPresetId: string | null
|
currentPresetId: string | null
|
||||||
presets: ConfigPreset[]
|
presets: ConfigPreset[]
|
||||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
userLocale?: "en" | "zh" | "ja"
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
const CONFIG_FILE_NAME = "config-presets.json"
|
||||||
@@ -159,10 +150,6 @@ function getConfigFilePath(): string {
|
|||||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The presets file exists but the last read failed: a save now would
|
|
||||||
// replace the user's presets with the empty list that read returned
|
|
||||||
let presetsUnreadable = false
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load presets from the config file
|
* Load presets from the config file
|
||||||
* Decrypts sensitive fields automatically
|
* Decrypts sensitive fields automatically
|
||||||
@@ -171,25 +158,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
// Nothing left that a save could overwrite
|
|
||||||
presetsUnreadable = false
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: string
|
|
||||||
try {
|
|
||||||
content = readFileSync(configPath, "utf-8")
|
|
||||||
presetsUnreadable = false
|
|
||||||
} catch (error) {
|
|
||||||
// Often only for now (on Windows an antivirus scanner can hold the
|
|
||||||
// file): keep the file, and refuse saves based on this empty list
|
|
||||||
console.error("Failed to read config presets:", error)
|
|
||||||
presetsUnreadable = true
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
@@ -199,6 +167,7 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const content = readFileSync(configPath, "utf-8")
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
const data = JSON.parse(content) as ConfigPresetsFile
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
// Decrypt sensitive fields in each preset
|
||||||
@@ -210,17 +179,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config presets:", error)
|
console.error("Failed to load config presets:", error)
|
||||||
// Move the unreadable file aside so the next save can't overwrite
|
|
||||||
// the user's presets with an empty list
|
|
||||||
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
|
||||||
try {
|
|
||||||
renameSync(configPath, backupPath)
|
|
||||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
|
||||||
} catch (renameError) {
|
|
||||||
// Still there: refuse saves that would overwrite it
|
|
||||||
console.error("Failed to back up config presets:", renameError)
|
|
||||||
presetsUnreadable = true
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
@@ -235,11 +193,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
* Encrypts sensitive fields automatically
|
* Encrypts sensitive fields automatically
|
||||||
*/
|
*/
|
||||||
export function savePresets(data: ConfigPresetsFile): void {
|
export function savePresets(data: ConfigPresetsFile): void {
|
||||||
if (presetsUnreadable) {
|
|
||||||
throw new Error(
|
|
||||||
"The presets file could not be read, so it was not overwritten. Please try again.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const configPath = getConfigFilePath()
|
const configPath = getConfigFilePath()
|
||||||
const userDataPath = app.getPath("userData")
|
const userDataPath = app.getPath("userData")
|
||||||
|
|
||||||
@@ -258,11 +211,7 @@ export function savePresets(data: ConfigPresetsFile): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||||
// a truncated config file
|
|
||||||
const tempPath = `${configPath}.tmp`
|
|
||||||
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
|
||||||
renameSync(tempPath, configPath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save config presets:", error)
|
console.error("Failed to save config presets:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -358,10 +307,9 @@ export function deletePreset(id: string): boolean {
|
|||||||
|
|
||||||
data.presets.splice(index, 1)
|
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) {
|
if (data.currentPresetId === id) {
|
||||||
data.currentPresetId = null
|
data.currentPresetId = null
|
||||||
setPresetEnv(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
@@ -374,15 +322,13 @@ export function deletePreset(id: string): boolean {
|
|||||||
export function setCurrentPreset(id: string | null): boolean {
|
export function setCurrentPreset(id: string | null): boolean {
|
||||||
const data = loadPresets()
|
const data = loadPresets()
|
||||||
|
|
||||||
let preset: ConfigPreset | null = null
|
|
||||||
if (id !== null) {
|
if (id !== null) {
|
||||||
preset = data.presets.find((p) => p.id === id) || null
|
const preset = data.presets.find((p) => p.id === id)
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPresetEnv(preset)
|
|
||||||
data.currentPresetId = id
|
data.currentPresetId = id
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
return true
|
return true
|
||||||
@@ -413,29 +359,84 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
baseUrl: "MODELSCOPE_BASE_URL",
|
||||||
},
|
},
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock doesn't use API keys in the same way
|
// bedrock and ollama don't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
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
|
* 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 env: Record<string, string> = {}
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
for (const [key, value] of Object.entries(preset.config)) {
|
||||||
if (value !== undefined && value !== "") {
|
if (value !== undefined && value !== "") {
|
||||||
// A key that could not be decrypted is useless to the server
|
|
||||||
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
console.warn(
|
|
||||||
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
// Map generic AI_API_KEY to provider-specific key
|
||||||
else if (
|
if (
|
||||||
key === "AI_API_KEY" &&
|
key === "AI_API_KEY" &&
|
||||||
provider &&
|
provider &&
|
||||||
PROVIDER_ENV_MAP[provider]
|
PROVIDER_ENV_MAP[provider]
|
||||||
@@ -465,95 +466,11 @@ function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Values that env vars had before a preset first set them
|
|
||||||
* (from the system or .env files), and the keys the active preset set
|
|
||||||
*/
|
|
||||||
const originalEnv: Record<string, string | undefined> = {}
|
|
||||||
let presetEnvKeys: string[] = []
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the env vars of the previous preset with those of the given preset
|
|
||||||
* (null leaves no preset applied). Restoring first means switching presets
|
|
||||||
* never leaves the previous preset's base URL, model or key behind.
|
|
||||||
*/
|
|
||||||
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
|
||||||
for (const key of presetEnvKeys) {
|
|
||||||
if (originalEnv[key] === undefined) {
|
|
||||||
delete process.env[key]
|
|
||||||
} else {
|
|
||||||
process.env[key] = originalEnv[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = preset ? presetToEnv(preset) : {}
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
if (!(key in originalEnv)) {
|
|
||||||
originalEnv[key] = process.env[key]
|
|
||||||
}
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
presetEnvKeys = Object.keys(env)
|
|
||||||
|
|
||||||
writeDevPresetEnv(env)
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Development only: write the active preset's env vars (decrypted and mapped)
|
|
||||||
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
|
||||||
* this file changes. The dev server can't decrypt the config file itself.
|
|
||||||
*/
|
|
||||||
function writeDevPresetEnv(env: Record<string, string>): void {
|
|
||||||
if (app.isPackaged) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
|
||||||
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o600,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to write dev preset env:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply preset environment variables to the current process
|
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
|
||||||
|
|
||||||
if (!preset) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEnv = setPresetEnv(preset)
|
|
||||||
|
|
||||||
// Set as current preset
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return appliedEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the saved current preset's environment variables (used at startup)
|
|
||||||
*/
|
|
||||||
export function applyCurrentPresetToEnv(): void {
|
|
||||||
setPresetEnv(getCurrentPreset())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's preferred locale from config
|
* Get user's preferred locale from config
|
||||||
* Returns undefined if not set
|
* Returns undefined if not set
|
||||||
*/
|
*/
|
||||||
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
||||||
const data = loadPresets()
|
const data = loadPresets()
|
||||||
return data.userLocale
|
return data.userLocale
|
||||||
}
|
}
|
||||||
@@ -561,9 +478,7 @@ export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
|||||||
/**
|
/**
|
||||||
* Set user's preferred locale in config
|
* Set user's preferred locale in config
|
||||||
*/
|
*/
|
||||||
export function setUserLocale(
|
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
|
||||||
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
|
||||||
): void {
|
|
||||||
const data = loadPresets()
|
const data = loadPresets()
|
||||||
data.userLocale = locale === null ? undefined : locale
|
data.userLocale = locale === null ? undefined : locale
|
||||||
savePresets(data)
|
savePresets(data)
|
||||||
|
|||||||
@@ -28,25 +28,6 @@ export function loadEnvFile(): void {
|
|||||||
console.log("No .env file found, using system environment variables")
|
console.log("No .env file found, using system environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Index of the quote that closes a value starting with a quote, or -1. A
|
|
||||||
* backslash before the quote character escapes it, as in dotenv; the
|
|
||||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
|
||||||
* comment or nothing after it still closes the value when no other quote
|
|
||||||
* does ("C:\dir\" keeps its trailing backslash).
|
|
||||||
*/
|
|
||||||
function findClosingQuote(value: string): number {
|
|
||||||
const quote = value[0]
|
|
||||||
let lastEscaped = -1
|
|
||||||
for (let i = 1; i < value.length; i++) {
|
|
||||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
|
||||||
i++
|
|
||||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
|
||||||
} else if (value[i] === quote) return i
|
|
||||||
}
|
|
||||||
return lastEscaped
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and load environment variables from a file
|
* Parse and load environment variables from a file
|
||||||
*/
|
*/
|
||||||
@@ -67,28 +48,12 @@ function loadEnvFromFile(filePath: string): void {
|
|||||||
const key = trimmed.slice(0, equalIndex).trim()
|
const key = trimmed.slice(0, equalIndex).trim()
|
||||||
let value = trimmed.slice(equalIndex + 1).trim()
|
let value = trimmed.slice(equalIndex + 1).trim()
|
||||||
|
|
||||||
const quote = value[0]
|
// Remove surrounding quotes
|
||||||
const closingQuote =
|
|
||||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
|
||||||
if (
|
if (
|
||||||
closingQuote > 0 &&
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
) {
|
) {
|
||||||
// Quoted value, then nothing or a comment: keep what is
|
value = value.slice(1, -1)
|
||||||
// inside the quotes, as dotenv reads it
|
|
||||||
value = value.slice(1, closingQuote)
|
|
||||||
} else {
|
|
||||||
// Unquoted value: drop an inline comment ("value # comment").
|
|
||||||
// A value quoted from start to end with quotes inside (JSON
|
|
||||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
|
||||||
value = value.replace(/\s+#.*$/, "")
|
|
||||||
if (
|
|
||||||
closingQuote > 0 &&
|
|
||||||
value.length > 1 &&
|
|
||||||
value.endsWith(quote)
|
|
||||||
) {
|
|
||||||
value = value.slice(1, -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't override existing environment variables
|
// Don't override existing environment variables
|
||||||
|
|||||||
+19
-50
@@ -1,17 +1,12 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
import { getCurrentPresetEnv } from "./config-manager"
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
import { applyProxyToEnv } from "./proxy-manager"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import {
|
import { createWindow, getMainWindow } from "./window-manager"
|
||||||
createWindow,
|
|
||||||
getAppUrl,
|
|
||||||
getMainWindow,
|
|
||||||
isAppUrl,
|
|
||||||
} from "./window-manager"
|
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
@@ -33,14 +28,16 @@ if (!gotTheLock) {
|
|||||||
// Apply proxy settings from saved config
|
// Apply proxy settings from saved config
|
||||||
applyProxyToEnv()
|
applyProxyToEnv()
|
||||||
|
|
||||||
const isDev = !app.isPackaged
|
// Apply saved preset environment variables (overrides .env)
|
||||||
|
const presetEnv = getCurrentPresetEnv()
|
||||||
|
for (const [key, value] of Object.entries(presetEnv)) {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
|
let serverUrl: string | null = null
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Apply saved preset environment variables (overrides .env).
|
|
||||||
// Must run after ready: on Windows and Linux safeStorage can't
|
|
||||||
// decrypt the API key before that.
|
|
||||||
applyCurrentPresetToEnv()
|
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
registerSettingsWindowHandlers()
|
||||||
@@ -49,7 +46,6 @@ if (!gotTheLock) {
|
|||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let serverUrl: string
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Development: use the dev server URL
|
// Development: use the dev server URL
|
||||||
serverUrl =
|
serverUrl =
|
||||||
@@ -73,9 +69,8 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
const appUrl = getAppUrl()
|
if (serverUrl) {
|
||||||
if (appUrl) {
|
createWindow(serverUrl)
|
||||||
createWindow(appUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -92,49 +87,23 @@ if (!gotTheLock) {
|
|||||||
stopNextServer()
|
stopNextServer()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pages allowed inside app windows: the app server and draw.io
|
|
||||||
const isInAppUrl = (url: string): boolean => {
|
|
||||||
if (isAppUrl(url)) return true
|
|
||||||
try {
|
|
||||||
const { hostname } = new URL(url)
|
|
||||||
return ["diagrams.net", "draw.io"].some(
|
|
||||||
(domain) =>
|
|
||||||
hostname === domain || hostname.endsWith(`.${domain}`),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWebUrl = (url: string): boolean =>
|
|
||||||
url.startsWith("http://") || url.startsWith("https://")
|
|
||||||
|
|
||||||
// Open external links in default browser
|
// Open external links in default browser
|
||||||
app.on("web-contents-created", (_, contents) => {
|
app.on("web-contents-created", (_, contents) => {
|
||||||
contents.setWindowOpenHandler(({ url }) => {
|
contents.setWindowOpenHandler(({ url }) => {
|
||||||
if (isInAppUrl(url)) {
|
// Allow diagrams.net iframe
|
||||||
|
if (
|
||||||
|
url.includes("diagrams.net") ||
|
||||||
|
url.includes("draw.io") ||
|
||||||
|
url.startsWith("http://localhost")
|
||||||
|
) {
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
// Open other links in external browser
|
// Open other links in external browser
|
||||||
if (isWebUrl(url)) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
shell.openExternal(url)
|
shell.openExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clicking a plain link would otherwise replace the app page with
|
|
||||||
// an external site that keeps the preload API. Only the page
|
|
||||||
// itself may navigate there; draw.io stays in its frame (this event
|
|
||||||
// is for the main frame only)
|
|
||||||
contents.on("will-navigate", (event) => {
|
|
||||||
if (isAppUrl(event.url)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.preventDefault()
|
|
||||||
if (isWebUrl(event.url)) {
|
|
||||||
shell.openExternal(event.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-92
@@ -1,6 +1,7 @@
|
|||||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
import { rebuildAppMenu } from "./app-menu"
|
||||||
import {
|
import {
|
||||||
|
applyPresetToEnv,
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
createPreset,
|
createPreset,
|
||||||
deletePreset,
|
deletePreset,
|
||||||
@@ -13,14 +14,12 @@ import {
|
|||||||
updatePreset,
|
updatePreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
|
||||||
import {
|
import {
|
||||||
applyProxyToEnv,
|
applyProxyToEnv,
|
||||||
getProxyConfig,
|
getProxyConfig,
|
||||||
type ProxyConfig,
|
type ProxyConfig,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
} from "./proxy-manager"
|
} from "./proxy-manager"
|
||||||
import { isAppUrl } from "./window-manager"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -49,44 +48,16 @@ function sanitizePresetConfig(
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an IPC handler that only answers the app's own pages
|
|
||||||
* (the main window on the app server, or the local settings page).
|
|
||||||
* A main window that somehow ends up on an external site still gets the
|
|
||||||
* preload API, so its calls must be rejected here.
|
|
||||||
*/
|
|
||||||
function handle<Args extends unknown[]>(
|
|
||||||
channel: string,
|
|
||||||
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
|
||||||
): void {
|
|
||||||
ipcMain.handle(channel, (event, ...args) => {
|
|
||||||
const url = event.senderFrame?.url
|
|
||||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
|
||||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
|
||||||
}
|
|
||||||
return listener(event, ...(args as Args))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
*/
|
*/
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ==================== App Info ====================
|
// ==================== App Info ====================
|
||||||
|
|
||||||
handle("get-version", () => {
|
ipcMain.handle("get-version", () => {
|
||||||
return app.getVersion()
|
return app.getVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Where the chats are ====================
|
|
||||||
|
|
||||||
// The page saved a chat, or loaded without any: decides which port
|
|
||||||
// (and so which origin's chats) the next launch opens
|
|
||||||
handle("chat-saved", () => rememberChatPort())
|
|
||||||
handle("chats-loaded", (_event, count: unknown) => {
|
|
||||||
if (count === 0) noteNoChats()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Window Controls ====================
|
// ==================== Window Controls ====================
|
||||||
|
|
||||||
ipcMain.on("window-minimize", (event) => {
|
ipcMain.on("window-minimize", (event) => {
|
||||||
@@ -108,30 +79,84 @@ export function registerIpcHandlers(): void {
|
|||||||
win?.close()
|
win?.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== File Dialogs ====================
|
||||||
|
|
||||||
|
ipcMain.handle("dialog-open-file", async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win) return null
|
||||||
|
|
||||||
|
const result = await dialog.showOpenDialog(win, {
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [
|
||||||
|
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
||||||
|
{ name: "All Files", extensions: ["*"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
const fs = await import("node:fs/promises")
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
||||||
|
return content
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to read file:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win) return false
|
||||||
|
|
||||||
|
const result = await dialog.showSaveDialog(win, {
|
||||||
|
filters: [
|
||||||
|
{ name: "Draw.io Files", extensions: ["drawio"] },
|
||||||
|
{ name: "XML Files", extensions: ["xml"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = await import("node:fs/promises")
|
||||||
|
try {
|
||||||
|
await fs.writeFile(result.filePath, data, "utf-8")
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save file:", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
// ==================== Config Presets ====================
|
||||||
|
|
||||||
handle("config-presets:get-all", () => {
|
ipcMain.handle("config-presets:get-all", () => {
|
||||||
return getAllPresets()
|
return getAllPresets()
|
||||||
})
|
})
|
||||||
|
|
||||||
handle("config-presets:get-current", () => {
|
ipcMain.handle("config-presets:get-current", () => {
|
||||||
return getCurrentPreset()
|
return getCurrentPreset()
|
||||||
})
|
})
|
||||||
|
|
||||||
handle("config-presets:get-current-id", () => {
|
ipcMain.handle("config-presets:get-current-id", () => {
|
||||||
return getCurrentPresetId()
|
return getCurrentPresetId()
|
||||||
})
|
})
|
||||||
|
|
||||||
handle(
|
ipcMain.handle(
|
||||||
"config-presets:save",
|
"config-presets:save",
|
||||||
async (
|
(
|
||||||
_event,
|
_event,
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||||
id?: string
|
id?: string
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// Validate preset name
|
// Validate preset name
|
||||||
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
||||||
throw new Error("Invalid preset name")
|
throw new Error("Invalid preset name")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,48 +165,42 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
if (preset.id) {
|
if (preset.id) {
|
||||||
// Update existing preset
|
// Update existing preset
|
||||||
const updated = updatePreset(preset.id, {
|
return updatePreset(preset.id, {
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
// Re-apply the active preset so the edit takes effect
|
|
||||||
if (updated && updated.id === getCurrentPresetId()) {
|
|
||||||
await switchPreset(updated.id)
|
|
||||||
} else {
|
|
||||||
rebuildAppMenu()
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
}
|
||||||
// Create new preset
|
// Create new preset
|
||||||
const created = createPreset({
|
return createPreset({
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
config: sanitizedConfig,
|
config: sanitizedConfig,
|
||||||
})
|
})
|
||||||
rebuildAppMenu()
|
|
||||||
return created
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
handle("config-presets:delete", async (_event, id: string) => {
|
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
||||||
const wasCurrent = id === getCurrentPresetId()
|
return deletePreset(id)
|
||||||
// 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const env = await switchPreset(id)
|
await restartNextServer()
|
||||||
// In development mode, electron-dev.mjs restarts Next.js
|
return { success: true, env }
|
||||||
return app.isPackaged
|
|
||||||
? { success: true, env }
|
|
||||||
: { success: true, env, devMode: true }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -193,39 +212,30 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
ipcMain.handle(
|
||||||
return setCurrentPreset(id)
|
"config-presets:set-current",
|
||||||
})
|
(_event, id: string | null) => {
|
||||||
|
return setCurrentPreset(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
// ==================== Proxy Settings ====================
|
||||||
|
|
||||||
handle("get-proxy", () => {
|
ipcMain.handle("get-proxy", () => {
|
||||||
return getProxyConfig()
|
return getProxyConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||||
const isOptionalString = (value: unknown) =>
|
|
||||||
value === undefined || typeof value === "string"
|
|
||||||
if (
|
|
||||||
typeof config !== "object" ||
|
|
||||||
config === null ||
|
|
||||||
!isOptionalString(config.httpProxy) ||
|
|
||||||
!isOptionalString(config.httpsProxy)
|
|
||||||
) {
|
|
||||||
return { success: false, error: "Invalid proxy settings" }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save config to file
|
// Save config to file
|
||||||
saveProxyConfig({
|
saveProxyConfig(config)
|
||||||
httpProxy: config.httpProxy,
|
|
||||||
httpsProxy: config.httpsProxy,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apply to current process environment
|
// Apply to current process environment
|
||||||
applyProxyToEnv()
|
applyProxyToEnv()
|
||||||
|
|
||||||
if (!app.isPackaged) {
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
// In development, env vars are already applied
|
// In development, env vars are already applied
|
||||||
// Next.js dev server may need manual restart
|
// Next.js dev server may need manual restart
|
||||||
return { success: true, devMode: true }
|
return { success: true, devMode: true }
|
||||||
@@ -247,18 +257,18 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ==================== User Locale ====================
|
// ==================== User Locale ====================
|
||||||
|
|
||||||
handle("get-user-locale", () => {
|
ipcMain.handle("get-user-locale", () => {
|
||||||
return getUserLocale()
|
return getUserLocale()
|
||||||
})
|
})
|
||||||
|
|
||||||
handle("set-user-locale", (_event, locale: string) => {
|
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
||||||
// Validate locale is one of the supported values
|
// Validate locale is one of the supported values
|
||||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
if (!["en", "zh", "ja"].includes(locale)) {
|
||||||
return { success: false, error: "Invalid locale" }
|
return { success: false, error: "Invalid locale" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
setUserLocale(locale as "en" | "zh" | "ja")
|
||||||
// Rebuild the menu to reflect the new locale
|
// Rebuild the menu to reflect the new locale
|
||||||
rebuildAppMenu()
|
rebuildAppMenu()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { getUserLocale } from "./config-manager"
|
import { getUserLocale } from "./config-manager"
|
||||||
|
|
||||||
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
export type MenuLocale = "en" | "zh" | "ja"
|
||||||
|
|
||||||
export interface MenuTranslations {
|
export interface MenuTranslations {
|
||||||
// App menu (macOS only)
|
// App menu (macOS only)
|
||||||
@@ -119,34 +119,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
documentation: "ドキュメント",
|
documentation: "ドキュメント",
|
||||||
reportIssue: "問題を報告",
|
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: "回報問題",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,16 +126,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
* Falls back to English if locale is not supported
|
* Falls back to English if locale is not supported
|
||||||
*/
|
*/
|
||||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
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")
|
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||||
const normalized = locale.toLowerCase().split("-")[0]
|
const normalized = locale.toLowerCase().split("-")[0]
|
||||||
|
|
||||||
@@ -174,21 +136,10 @@ export function getMenuTranslations(locale: string): MenuTranslations {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect system locale from Electron app
|
* Detect system locale from Electron app
|
||||||
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
* Returns one of: "en", "zh", "ja"
|
||||||
*/
|
*/
|
||||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||||
const lower = appLocale.toLowerCase()
|
const normalized = appLocale.toLowerCase().split("-")[0]
|
||||||
|
|
||||||
// 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 === "zh") return "zh"
|
||||||
if (normalized === "ja") return "ja"
|
if (normalized === "ja") return "ja"
|
||||||
|
|||||||
@@ -7,20 +7,9 @@ import {
|
|||||||
getServerUrl,
|
getServerUrl,
|
||||||
isPortAvailable,
|
isPortAvailable,
|
||||||
} from "./port-manager"
|
} from "./port-manager"
|
||||||
import { setAppUrl } from "./window-manager"
|
|
||||||
|
|
||||||
let serverProcess: UtilityProcess | null = null
|
let serverProcess: UtilityProcess | null = null
|
||||||
|
|
||||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
|
||||||
// preset switches) can't leave two servers running
|
|
||||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
|
||||||
|
|
||||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
|
||||||
const result = serverQueue.then(task)
|
|
||||||
serverQueue = result.catch(() => {})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the standalone server resources
|
* Get the path to the standalone server resources
|
||||||
* In packaged app: resources/standalone
|
* In packaged app: resources/standalone
|
||||||
@@ -56,11 +45,7 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
|||||||
* Start the Next.js standalone server using Electron's utilityProcess
|
* Start the Next.js standalone server using Electron's utilityProcess
|
||||||
* This API is designed for running Node.js code in the background
|
* This API is designed for running Node.js code in the background
|
||||||
*/
|
*/
|
||||||
export function startNextServer(): Promise<string> {
|
export async function startNextServer(): Promise<string> {
|
||||||
return runExclusive(startServer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startServer(): Promise<string> {
|
|
||||||
const resourcePath = getResourcePath()
|
const resourcePath = getResourcePath()
|
||||||
const serverPath = path.join(resourcePath, "server.js")
|
const serverPath = path.join(resourcePath, "server.js")
|
||||||
|
|
||||||
@@ -83,16 +68,9 @@ async function startServer(): Promise<string> {
|
|||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "127.0.0.1",
|
HOSTNAME: "localhost",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
// The preset keys are the user's own, not a server's
|
|
||||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
|
||||||
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
|
||||||
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
@@ -118,27 +96,23 @@ async function startServer(): Promise<string> {
|
|||||||
|
|
||||||
// Use Electron's utilityProcess API for running Node.js in background
|
// Use Electron's utilityProcess API for running Node.js in background
|
||||||
// This is the recommended way to run Node.js code in Electron
|
// This is the recommended way to run Node.js code in Electron
|
||||||
const proc = utilityProcess.fork(serverPath, [], {
|
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||||
cwd: resourcePath,
|
cwd: resourcePath,
|
||||||
env,
|
env,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
serverProcess = proc
|
|
||||||
|
|
||||||
proc.stdout?.on("data", (data) => {
|
serverProcess.stdout?.on("data", (data) => {
|
||||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
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()}`)
|
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}`)
|
console.log(`Next.js server exited with code ${code}`)
|
||||||
// An old server can exit after a new one started; keep the new one
|
serverProcess = null
|
||||||
if (serverProcess === proc) {
|
|
||||||
serverProcess = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = getServerUrl()
|
const url = getServerUrl()
|
||||||
@@ -152,36 +126,39 @@ async function startServer(): Promise<string> {
|
|||||||
* Stop the Next.js server process and wait for it to exit
|
* Stop the Next.js server process and wait for it to exit
|
||||||
*/
|
*/
|
||||||
export async function stopNextServer(): Promise<void> {
|
export async function stopNextServer(): Promise<void> {
|
||||||
const proc = serverProcess
|
if (serverProcess) {
|
||||||
if (!proc) {
|
console.log("Stopping Next.js server...")
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log("Stopping Next.js server...")
|
|
||||||
serverProcess = null
|
|
||||||
|
|
||||||
// Resolves true when the process exits, false after the timeout
|
// Create a promise that resolves when the process exits
|
||||||
const waitForExit = (ms: number) =>
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
new Promise<boolean>((resolve) => {
|
const proc = serverProcess
|
||||||
proc.once("exit", () => resolve(true))
|
if (!proc) {
|
||||||
setTimeout(() => resolve(false), ms)
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExit = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.once("exit", onExit)
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
proc.removeListener("exit", onExit)
|
||||||
|
resolve()
|
||||||
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.kill()
|
serverProcess.kill()
|
||||||
|
serverProcess = null
|
||||||
|
|
||||||
// Next.js waits for open requests (e.g. a streaming reply) before it
|
// Wait for process to exit
|
||||||
// exits, so force kill it if it is still running after 5 seconds
|
await exitPromise
|
||||||
if (!(await waitForExit(5000)) && proc.pid) {
|
|
||||||
console.warn("Next.js server did not exit in time, force killing it")
|
// Additional wait for OS to release port
|
||||||
try {
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,19 +184,15 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Restart the Next.js server with new environment variables
|
* Restart the Next.js server with new environment variables
|
||||||
*/
|
*/
|
||||||
export function restartNextServer(): Promise<string> {
|
export async function restartNextServer(): Promise<string> {
|
||||||
return runExclusive(async () => {
|
console.log("Restarting Next.js server...")
|
||||||
console.log("Restarting Next.js server...")
|
|
||||||
|
|
||||||
// Stop the current server and wait for it to exit
|
// Stop the current server and wait for it to exit
|
||||||
await stopNextServer()
|
await stopNextServer()
|
||||||
|
|
||||||
// Wait for the port to be released
|
// Wait for the port to be released
|
||||||
await waitForServerStop()
|
await waitForServerStop()
|
||||||
|
|
||||||
// Start the server again, and follow it if it moved to another port
|
// Start the server again
|
||||||
const url = await startServer()
|
return startNextServer()
|
||||||
setAppUrl(url)
|
|
||||||
return url
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-112
@@ -1,6 +1,4 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
||||||
import net from "node:net"
|
import net from "node:net"
|
||||||
import path from "node:path"
|
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,11 +9,9 @@ import { app } from "electron"
|
|||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Legacy production port — tried first to preserve localStorage for existing users
|
// Production mode uses fixed port (61337) to preserve localStorage
|
||||||
legacyProduction: 61337,
|
// Falls back to sequential ports if unavailable
|
||||||
// New production port below the ephemeral range (49152-65535)
|
production: 61337,
|
||||||
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
|
||||||
production: 13370,
|
|
||||||
// Maximum attempts to find an available port (fallback)
|
// Maximum attempts to find an available port (fallback)
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
@@ -25,79 +21,13 @@ const PORT_CONFIG = {
|
|||||||
*/
|
*/
|
||||||
let allocatedPort: number | null = null
|
let allocatedPort: number | null = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
|
|
||||||
* each origin's IndexedDB in its own folder
|
|
||||||
*/
|
|
||||||
function hasStoredData(port: number): boolean {
|
|
||||||
return existsSync(
|
|
||||||
path.join(
|
|
||||||
app.getPath("userData"),
|
|
||||||
"IndexedDB",
|
|
||||||
`http_127.0.0.1_${port}.indexeddb.leveldb`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two fixed production ports, the only ones whose origin (and so its
|
|
||||||
// chats and settings) is the same at every launch
|
|
||||||
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
|
||||||
|
|
||||||
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
|
||||||
|
|
||||||
/** The fixed port where a chat was last saved, if known */
|
|
||||||
function readChatPort(): number | null {
|
|
||||||
try {
|
|
||||||
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
|
||||||
return HOME_PORTS.includes(port) ? port : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeChatPort(port: number): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Could not save the chat port:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The page saved a chat: open on this port next time. Chats of the two
|
|
||||||
* ports cannot be shown together (each origin has its own storage), so the
|
|
||||||
* app opens where the user last worked. A launch that had to use the other
|
|
||||||
* port and saved nothing does not move it.
|
|
||||||
*/
|
|
||||||
export function rememberChatPort(): void {
|
|
||||||
const port = allocatedPort
|
|
||||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
|
||||||
if (readChatPort() !== port) writeChatPort(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The page loaded without any chats. Before any chat was saved under this
|
|
||||||
* version (no file yet), the user's chats may be on the other fixed port,
|
|
||||||
* where an older version opened: try it first next time.
|
|
||||||
*/
|
|
||||||
export function noteNoChats(): void {
|
|
||||||
const port = allocatedPort
|
|
||||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
|
||||||
if (existsSync(chatPortFile())) return
|
|
||||||
const other = HOME_PORTS.find((p) => p !== port)
|
|
||||||
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific port is available
|
* Check if a specific port is available
|
||||||
*/
|
*/
|
||||||
export function isPortAvailable(port: number): Promise<boolean> {
|
export function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
server.once("error", () => resolve(false))
|
||||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -109,13 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
* - In production: uses fixed port (61337) to preserve localStorage
|
||||||
* localStorage; 13370 first when only it has saved chats
|
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
* - Last resort: lets the OS assign a port (port 0)
|
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
|
* @throws Error if no available port found after max attempts
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
@@ -135,24 +64,10 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, first the port where a chat was last saved. Without
|
// Try preferred port first
|
||||||
// one, the legacy port first to preserve existing users' data, unless
|
if (await isPortAvailable(preferredPort)) {
|
||||||
// only the new port has data: their app started on 13370 while Windows
|
allocatedPort = preferredPort
|
||||||
// reserved 61337, and 61337 being free now would hide it
|
return preferredPort
|
||||||
const chatPort = isDev ? null : readChatPort()
|
|
||||||
const candidates = isDev
|
|
||||||
? [preferredPort]
|
|
||||||
: chatPort !== null
|
|
||||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
|
||||||
: hasStoredData(PORT_CONFIG.production) &&
|
|
||||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
|
||||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
|
||||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
|
||||||
for (const port of candidates) {
|
|
||||||
if (await isPortAvailable(port)) {
|
|
||||||
allocatedPort = port
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -169,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: let the OS pick an available port
|
throw new Error(
|
||||||
console.warn(
|
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||||
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
|
||||||
)
|
)
|
||||||
const osPort = await new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.once("listening", () => {
|
|
||||||
const addr = server.address()
|
|
||||||
const port = (addr as net.AddressInfo).port
|
|
||||||
server.close(() => resolve(port))
|
|
||||||
})
|
|
||||||
server.listen(0, "127.0.0.1")
|
|
||||||
})
|
|
||||||
allocatedPort = osPort
|
|
||||||
console.log(`OS assigned port: ${osPort}`)
|
|
||||||
return osPort
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,5 +113,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://127.0.0.1:${allocatedPort}`
|
return `http://localhost:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,18 @@ function getConfigPath(): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load proxy configuration from JSON file
|
* Load proxy configuration from JSON file
|
||||||
* Returns null if the user never saved proxy settings (or the file is invalid)
|
|
||||||
*/
|
*/
|
||||||
export function loadProxyConfig(): ProxyConfig | null {
|
export function loadProxyConfig(): ProxyConfig {
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
const data = fs.readFileSync(configPath, "utf-8")
|
||||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
return JSON.parse(data) as ProxyConfig
|
||||||
return data as ProxyConfig
|
|
||||||
}
|
|
||||||
console.error("Ignoring invalid proxy config:", data)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load proxy config:", error)
|
console.error("Failed to load proxy config:", error)
|
||||||
}
|
}
|
||||||
return null
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +33,7 @@ export function loadProxyConfig(): ProxyConfig | null {
|
|||||||
export function saveProxyConfig(config: ProxyConfig): void {
|
export function saveProxyConfig(config: ProxyConfig): void {
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
// a truncated file
|
|
||||||
const tempPath = `${configPath}.tmp`
|
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
|
||||||
fs.renameSync(tempPath, configPath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save proxy config:", error)
|
console.error("Failed to save proxy config:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -55,11 +47,6 @@ export function saveProxyConfig(config: ProxyConfig): void {
|
|||||||
export function applyProxyToEnv(): void {
|
export function applyProxyToEnv(): void {
|
||||||
const config = loadProxyConfig()
|
const config = loadProxyConfig()
|
||||||
|
|
||||||
// No saved settings: keep proxy vars inherited from the system or .env
|
|
||||||
if (!config) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.httpProxy) {
|
if (config.httpProxy) {
|
||||||
process.env.HTTP_PROXY = config.httpProxy
|
process.env.HTTP_PROXY = config.httpProxy
|
||||||
process.env.http_proxy = config.httpProxy
|
process.env.http_proxy = config.httpProxy
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { app, BrowserWindow, screen } from "electron"
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
// URL of the app server the main window loads
|
|
||||||
let appUrl: string | null = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the icon path based on platform
|
* Get the icon path based on platform
|
||||||
* Note: electron-builder converts icon.png during packaging,
|
* Note: electron-builder converts icon.png during packaging,
|
||||||
@@ -31,7 +28,6 @@ function getIconPath(): string | undefined {
|
|||||||
* Create the main application window
|
* Create the main application window
|
||||||
*/
|
*/
|
||||||
export function createWindow(serverUrl: string): BrowserWindow {
|
export function createWindow(serverUrl: string): BrowserWindow {
|
||||||
appUrl = serverUrl
|
|
||||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -60,28 +56,17 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Open DevTools in development
|
// Open DevTools in development
|
||||||
if (!app.isPackaged) {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools()
|
mainWindow.webContents.openDevTools()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override the draw.io iframe's beforeunload handler so the window can
|
|
||||||
// close after the user edits text in a shape (fixes #815). Diagrams are
|
|
||||||
// already persisted via autosave, so the prompt is unnecessary.
|
|
||||||
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
})
|
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
mainWindow.on("closed", () => {
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle page title updates
|
// Handle page title updates
|
||||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||||
if (
|
if (title && !title.includes("localhost")) {
|
||||||
title &&
|
|
||||||
!title.includes("localhost") &&
|
|
||||||
!title.includes("127.0.0.1")
|
|
||||||
) {
|
|
||||||
mainWindow?.setTitle(title)
|
mainWindow?.setTitle(title)
|
||||||
} else {
|
} else {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -97,39 +82,3 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow
|
return mainWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the app server URL the main window loads
|
|
||||||
*/
|
|
||||||
export function getAppUrl(): string | null {
|
|
||||||
return appUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point the main window at the restarted app server (it can come up on a
|
|
||||||
* different port). On the same port the page fetches the new preset's
|
|
||||||
* server models instead of sending the old preset's choice; it is not
|
|
||||||
* reloaded, which would drop unsent attachments.
|
|
||||||
*/
|
|
||||||
export function setAppUrl(url: string): void {
|
|
||||||
if (url === appUrl) {
|
|
||||||
mainWindow?.webContents.send("server-restarted")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appUrl = url
|
|
||||||
mainWindow?.loadURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL belongs to the app server (same origin)
|
|
||||||
*/
|
|
||||||
export function isAppUrl(url: string | undefined): boolean {
|
|
||||||
if (!url || !appUrl) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(url).origin === new URL(appUrl).origin
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
maximize: () => ipcRenderer.send("window-maximize"),
|
maximize: () => ipcRenderer.send("window-maximize"),
|
||||||
close: () => ipcRenderer.send("window-close"),
|
close: () => ipcRenderer.send("window-close"),
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||||
|
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||||
|
|
||||||
// Proxy settings
|
// Proxy settings
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||||
@@ -27,18 +31,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||||
setUserLocale: (locale: string) =>
|
setUserLocale: (locale: string) =>
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
|
|
||||||
// A chat was saved, or the page loaded with this many chats: the next
|
|
||||||
// launch opens the port where the chats are
|
|
||||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
|
||||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
|
||||||
|
|
||||||
// The server restarted on the same port (another preset)
|
|
||||||
onServerRestarted: (callback: () => void) => {
|
|
||||||
const listener = () => callback()
|
|
||||||
ipcRenderer.on("server-restarted", listener)
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("server-restarted", listener)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -213,9 +213,6 @@ async function savePreset() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// closeModal() clears editingPresetId, so remember it for the toast
|
|
||||||
const isEdit = Boolean(editingPresetId)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saveBtn.disabled = true
|
saveBtn.disabled = true
|
||||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||||
@@ -223,7 +220,10 @@ async function savePreset() {
|
|||||||
await window.settingsAPI.savePreset(preset)
|
await window.settingsAPI.savePreset(preset)
|
||||||
await loadPresets()
|
await loadPresets()
|
||||||
closeModal()
|
closeModal()
|
||||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
showToast(
|
||||||
|
editingPresetId ? "Preset updated" : "Preset created",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save preset:", error)
|
console.error("Failed to save preset:", error)
|
||||||
showToast("Failed to save preset", "error")
|
showToast("Failed to save preset", "error")
|
||||||
@@ -265,6 +265,8 @@ async function applyPreset(id) {
|
|||||||
|
|
||||||
const result = await window.settingsAPI.applyPreset(id)
|
const result = await window.settingsAPI.applyPreset(id)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
currentPresetId = id
|
||||||
|
renderPresets()
|
||||||
showToast("Preset applied, server restarting...", "success")
|
showToast("Preset applied, server restarting...", "success")
|
||||||
} else {
|
} else {
|
||||||
showToast(result.error || "Failed to apply preset", "error")
|
showToast(result.error || "Failed to apply preset", "error")
|
||||||
@@ -272,9 +274,6 @@ async function applyPreset(id) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to apply preset:", error)
|
console.error("Failed to apply preset:", error)
|
||||||
showToast("Failed to apply preset", "error")
|
showToast("Failed to apply preset", "error")
|
||||||
} finally {
|
|
||||||
// Reload to show the active preset and reset the Apply button
|
|
||||||
await loadPresets()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-92
@@ -1,22 +1,12 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
||||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
|
||||||
# The first one becomes the default and the rest appear in the model picker.
|
|
||||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
|
||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
|
||||||
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
|
|
||||||
# and raise it only when they use their own API key, so this also caps cost on server keys.
|
|
||||||
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
|
||||||
# MAX_OUTPUT_TOKENS=64000
|
|
||||||
|
|
||||||
# AWS Bedrock Configuration
|
# AWS Bedrock Configuration
|
||||||
# AWS_REGION=us-east-1
|
# AWS_REGION=us-east-1
|
||||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||||
@@ -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)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
|
|
||||||
# Anthropic (Direct) Configuration
|
# Anthropic (Direct) Configuration
|
||||||
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
|
|
||||||
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
||||||
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
||||||
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
||||||
@@ -44,6 +33,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Google Generative AI Configuration
|
# Google Generative AI Configuration
|
||||||
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
||||||
|
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
|
||||||
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
||||||
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
||||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
||||||
@@ -69,19 +59,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_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
||||||
# AZURE_REASONING_SUMMARY=detailed
|
# AZURE_REASONING_SUMMARY=detailed
|
||||||
|
|
||||||
# Ollama Configuration (Local or Cloud)
|
# Ollama (Local) Configuration
|
||||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api)
|
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
||||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
|
||||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||||
|
|
||||||
# OpenRouter Configuration
|
# OpenRouter Configuration
|
||||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# AIHubMix Configuration
|
|
||||||
# AIHUBMIX_API_KEY=your-aihubmix-api-key
|
|
||||||
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
|
|
||||||
|
|
||||||
# DeepSeek Configuration
|
# DeepSeek Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||||
@@ -123,25 +108,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
|
|
||||||
# Temperature (Optional)
|
# Temperature (Optional)
|
||||||
# Controls randomness in AI responses. Lower = more deterministic.
|
# Controls randomness in AI responses. Lower = more deterministic.
|
||||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models).
|
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||||
# Claude 4.7 and later reject it; the request is then retried without it.
|
|
||||||
# TEMPERATURE=0
|
# TEMPERATURE=0
|
||||||
|
|
||||||
# Debug Logging (Optional)
|
|
||||||
# Log the structure of the messages each chat request sends to the model
|
|
||||||
# DEBUG_LLM_PAYLOAD=true
|
|
||||||
|
|
||||||
# Access Control (Optional)
|
# Access Control (Optional)
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||||
|
|
||||||
# Admin Panel (Optional)
|
|
||||||
# Set a password to enable the web admin panel at /admin, where most of the
|
|
||||||
# settings in this file can be edited at runtime (stored in data/settings.json,
|
|
||||||
# which takes precedence over environment variables).
|
|
||||||
# Leave unset to disable the admin panel entirely.
|
|
||||||
# ADMIN_PASSWORD=your-admin-password
|
|
||||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||||
# Use this to point to a self-hosted draw.io instance
|
# Use this to point to a self-hosted draw.io instance
|
||||||
@@ -162,61 +134,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Allow private/internal URLs for reverse proxy setups (default: true)
|
# Allow private/internal URLs for reverse proxy setups (default: true)
|
||||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||||
# ALLOW_PRIVATE_URLS=false
|
# ALLOW_PRIVATE_URLS=false
|
||||||
|
|
||||||
# Behind a CDN such as Cloudflare (Optional)
|
|
||||||
# The daily quota is counted per IP. By default the IP is the first
|
|
||||||
# X-Forwarded-For entry, which visitors can set to anything. Name the header
|
|
||||||
# your CDN fills with the visitor's real IP instead:
|
|
||||||
# CLIENT_IP_HEADER=cf-connecting-ip
|
|
||||||
# Then have the CDN add an X-Origin-Secret header with this value to every
|
|
||||||
# request. API calls without it get 403, so nobody can skip the CDN and fake
|
|
||||||
# the IP header above.
|
|
||||||
# ORIGIN_SECRET=a-long-random-string
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
# It also turns off the MCP server advertisement in the chat examples.
|
|
||||||
# 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,4 +1,5 @@
|
|||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
|
import { useRef } from "react"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
import type { DiagramOperation } from "@/components/chat/types"
|
||||||
import type {
|
import type {
|
||||||
ValidationState,
|
ValidationState,
|
||||||
@@ -6,15 +7,10 @@ import type {
|
|||||||
} from "@/components/chat/ValidationCard"
|
} from "@/components/chat/ValidationCard"
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
|
||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
// display_diagram replaces the document with this one page
|
|
||||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
|
||||||
|
|
||||||
interface ToolCall {
|
interface ToolCall {
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
@@ -52,10 +48,6 @@ type ValidateDiagramFn = (
|
|||||||
interface UseDiagramToolHandlersParams {
|
interface UseDiagramToolHandlersParams {
|
||||||
partialXmlRef: MutableRefObject<string>
|
partialXmlRef: MutableRefObject<string>
|
||||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||||
// Tool calls the streaming preview must leave alone (shared with it)
|
|
||||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
|
||||||
// Failed VLM validations in the current user turn (reset on each user message)
|
|
||||||
validationRetryCountRef: MutableRefObject<number>
|
|
||||||
chartXMLRef: MutableRefObject<string>
|
chartXMLRef: MutableRefObject<string>
|
||||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||||
@@ -64,11 +56,6 @@ interface UseDiagramToolHandlersParams {
|
|||||||
validateDiagram?: ValidateDiagramFn
|
validateDiagram?: ValidateDiagramFn
|
||||||
enableVlmValidation?: boolean
|
enableVlmValidation?: boolean
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
// Called when a screenshot check begins; the function it returns
|
|
||||||
// tells whether the user pressed Stop in this turn, also after the next
|
|
||||||
// message was sent. A check that has not started then is skipped (one
|
|
||||||
// already running is cancelled by the caller).
|
|
||||||
watchStop?: () => () => boolean
|
|
||||||
onValidationStateChange?: (
|
onValidationStateChange?: (
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
state: ValidationState,
|
state: ValidationState,
|
||||||
@@ -85,8 +72,6 @@ interface UseDiagramToolHandlersParams {
|
|||||||
export function useDiagramToolHandlers({
|
export function useDiagramToolHandlers({
|
||||||
partialXmlRef,
|
partialXmlRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
processedToolCallsRef,
|
|
||||||
validationRetryCountRef,
|
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
@@ -95,9 +80,11 @@ export function useDiagramToolHandlers({
|
|||||||
validateDiagram,
|
validateDiagram,
|
||||||
enableVlmValidation = true,
|
enableVlmValidation = true,
|
||||||
sessionId,
|
sessionId,
|
||||||
watchStop,
|
|
||||||
onValidationStateChange,
|
onValidationStateChange,
|
||||||
}: UseDiagramToolHandlersParams) {
|
}: UseDiagramToolHandlersParams) {
|
||||||
|
// Track validation retry count per tool call
|
||||||
|
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
|
||||||
|
|
||||||
// Helper to update validation state
|
// Helper to update validation state
|
||||||
const updateValidationState = (
|
const updateValidationState = (
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
@@ -127,39 +114,18 @@ export function useDiagramToolHandlers({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
processedToolCallsRef.current.add(toolCall.toolCallId)
|
|
||||||
// Only display_diagram, edit_diagram and a completing append_diagram
|
|
||||||
// put their result on the canvas. Other tools (get_shape_library,
|
|
||||||
// which the server runs, still arrives here) leave the stored
|
|
||||||
// originals for the preview code to undo.
|
|
||||||
if (toolCall.toolName === "display_diagram") {
|
if (toolCall.toolName === "display_diagram") {
|
||||||
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals())
|
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
|
await handleEditDiagram(toolCall, addToolOutput)
|
||||||
} else if (toolCall.toolName === "append_diagram") {
|
} else if (toolCall.toolName === "append_diagram") {
|
||||||
handleAppendDiagram(toolCall, addToolOutput)
|
handleAppendDiagram(toolCall, addToolOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stored originals belong to previews not handled yet: this call's, and
|
|
||||||
// those of earlier calls with invalid input, which never get to the
|
|
||||||
// handler. The first is the diagram before all of them. A call that
|
|
||||||
// draws its result replaces those previews, so the preview code must
|
|
||||||
// neither draw them again nor undo them later. Returns that first one.
|
|
||||||
const takeOriginals = (): string | undefined => {
|
|
||||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
|
||||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
|
||||||
processedToolCallsRef.current.add(id)
|
|
||||||
}
|
|
||||||
editDiagramOriginalXmlRef.current.clear()
|
|
||||||
return originalXml
|
|
||||||
}
|
|
||||||
|
|
||||||
// originalXml: the diagram before the streamed previews, if any were drawn
|
|
||||||
const handleDisplayDiagram = async (
|
const handleDisplayDiagram = async (
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
originalXml: string | undefined,
|
|
||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
@@ -208,18 +174,14 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
|||||||
const finalXml = xml
|
const finalXml = xml
|
||||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||||
|
|
||||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
// Wrap raw XML with full mxfile structure for draw.io
|
||||||
// create_new_diagram, then load it
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
|
||||||
const validationError = prepared.ok
|
// loadDiagram validates and returns error if invalid
|
||||||
? onDisplayChart(prepared.xml, true)
|
const validationError = onDisplayChart(fullXml)
|
||||||
: prepared.error
|
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
console.warn("[display_diagram] Validation error:", validationError)
|
console.warn("[display_diagram] Validation error:", validationError)
|
||||||
// Undo the streamed preview, as a failed edit does: the canvas
|
|
||||||
// keeps the diagram from before this failed call
|
|
||||||
if (originalXml) onDisplayChart(originalXml, true)
|
|
||||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -251,12 +213,9 @@ ${finalXml}
|
|||||||
if (
|
if (
|
||||||
enableVlmValidation &&
|
enableVlmValidation &&
|
||||||
captureValidationPng &&
|
captureValidationPng &&
|
||||||
validateDiagram &&
|
validateDiagram
|
||||||
// At most this many checks per user turn, passed or not
|
|
||||||
validationRetryCountRef.current < MAX_VALIDATION_RETRIES
|
|
||||||
) {
|
) {
|
||||||
let capturedPngData: string | null = null
|
let capturedPngData: string | null = null
|
||||||
const stopped = watchStop?.()
|
|
||||||
try {
|
try {
|
||||||
// Notify UI that we're starting capture
|
// Notify UI that we're starting capture
|
||||||
updateValidationState(toolCall.toolCallId, "capturing")
|
updateValidationState(toolCall.toolCallId, "capturing")
|
||||||
@@ -266,28 +225,24 @@ ${finalXml}
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
capturedPngData = await captureValidationPng()
|
capturedPngData = await captureValidationPng()
|
||||||
// Stopped while the screenshot was taken: no check. The
|
if (capturedPngData) {
|
||||||
// chat waits for this handler, so it must end now.
|
|
||||||
if (stopped?.()) {
|
|
||||||
updateValidationState(toolCall.toolCallId, "skipped")
|
|
||||||
} else if (capturedPngData) {
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
"[display_diagram] Captured PNG for validation",
|
"[display_diagram] Captured PNG for validation",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each retry is a new tool call, so count attempts
|
const retryCount =
|
||||||
// per user turn (the chat resets it when the user sends)
|
validationRetryCountRef.current.get(
|
||||||
const attempt = validationRetryCountRef.current + 1
|
toolCall.toolCallId,
|
||||||
validationRetryCountRef.current = attempt
|
) || 0
|
||||||
|
|
||||||
// Notify UI that we're validating (include the image)
|
// Notify UI that we're validating (include the image)
|
||||||
updateValidationState(
|
updateValidationState(
|
||||||
toolCall.toolCallId,
|
toolCall.toolCallId,
|
||||||
"validating",
|
"validating",
|
||||||
{
|
{
|
||||||
attempt,
|
attempt: retryCount + 1,
|
||||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
imageData: capturedPngData,
|
imageData: capturedPngData,
|
||||||
},
|
},
|
||||||
@@ -299,12 +254,17 @@ ${finalXml}
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||||
|
validationRetryCountRef.current.set(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
retryCount + 1,
|
||||||
|
)
|
||||||
|
|
||||||
const feedback =
|
const feedback =
|
||||||
formatValidationFeedback(result)
|
formatValidationFeedback(result)
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||||
result.issues,
|
result.issues,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -314,7 +274,7 @@ ${finalXml}
|
|||||||
toolCall.toolCallId,
|
toolCall.toolCallId,
|
||||||
"failed",
|
"failed",
|
||||||
{
|
{
|
||||||
attempt,
|
attempt: retryCount + 1,
|
||||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
result,
|
result,
|
||||||
imageData: capturedPngData,
|
imageData: capturedPngData,
|
||||||
@@ -325,16 +285,20 @@ ${finalXml}
|
|||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Last attempt - accept the diagram with warning
|
// Max retries reached - accept the diagram with warning
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
|
||||||
// Notify UI that we're accepting with issues (include the image)
|
// Notify UI that we're accepting with issues (include the image)
|
||||||
updateValidationState(
|
updateValidationState(
|
||||||
toolCall.toolCallId,
|
toolCall.toolCallId,
|
||||||
@@ -350,6 +314,10 @@ ${finalXml}
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Validation passed - clean up retry count
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
"[display_diagram] Validation passed!",
|
"[display_diagram] Validation passed!",
|
||||||
@@ -372,16 +340,6 @@ ${finalXml}
|
|||||||
updateValidationState(toolCall.toolCallId, "skipped")
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Cancelled by Stop: the diagram stays, unchecked
|
|
||||||
if ((error as Error)?.name === "AbortError") {
|
|
||||||
updateValidationState(toolCall.toolCallId, "skipped")
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: "Successfully displayed the diagram.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// VLM validation error - log but don't block the user
|
// VLM validation error - log but don't block the user
|
||||||
console.warn(
|
console.warn(
|
||||||
"[display_diagram] VLM validation error:",
|
"[display_diagram] VLM validation error:",
|
||||||
@@ -415,24 +373,21 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// originalXml: the diagram before the streamed previews, if any were drawn.
|
|
||||||
// Operations apply to it, the same base XML that streaming used.
|
|
||||||
const handleEditDiagram = async (
|
const handleEditDiagram = async (
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
originalXml: string | undefined,
|
|
||||||
) => {
|
) => {
|
||||||
const { operations } = toolCall.input as {
|
const { operations } = toolCall.input as {
|
||||||
operations: DiagramOperation[]
|
operations: DiagramOperation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentXml = ""
|
let currentXml = ""
|
||||||
// On failure, undo the streaming preview so the canvas matches the XML
|
|
||||||
// reported back to the model
|
|
||||||
const restoreOriginal = () => {
|
|
||||||
if (originalXml) onDisplayChart(originalXml, true)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||||
|
// This ensures we apply operations to the same base XML that streaming used
|
||||||
|
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
if (originalXml) {
|
if (originalXml) {
|
||||||
currentXml = originalXml
|
currentXml = originalXml
|
||||||
} else {
|
} else {
|
||||||
@@ -446,19 +401,26 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All or nothing, checked like the MCP server's edit_diagram.
|
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||||
// The model sees the first page, so edits target it.
|
const { result: editedXml, errors } = applyDiagramOperations(
|
||||||
const outcome = editDiagram(currentXml, operations, {})
|
currentXml,
|
||||||
if (!outcome.ok) {
|
operations,
|
||||||
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")}`
|
// Check for operation errors
|
||||||
restoreOriginal()
|
if (errors.length > 0) {
|
||||||
|
const errorMessages = errors
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: `${reason}
|
errorText: `Some operations failed:\n${errorMessages}
|
||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
@@ -467,23 +429,49 @@ ${currentXml}
|
|||||||
|
|
||||||
Please check the cell IDs and retry.`,
|
Please check the cell IDs and retry.`,
|
||||||
})
|
})
|
||||||
|
// Clean up the shared original XML ref
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
onExport()
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
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)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[edit_diagram] Failed:", error)
|
console.error("[edit_diagram] Failed:", error)
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
restoreOriginal()
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
@@ -497,6 +485,8 @@ ${currentXml || "No XML available"}
|
|||||||
|
|
||||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||||
})
|
})
|
||||||
|
// Clean up the shared original XML ref even on error
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,19 +496,6 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
|||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
// Nothing to continue: loading the fragment alone would replace the whole diagram
|
|
||||||
if (!partialXmlRef.current) {
|
|
||||||
addToolOutput({
|
|
||||||
tool: "append_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
|
|
||||||
|
|
||||||
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if LLM incorrectly started fresh instead of continuing
|
// Detect if LLM incorrectly started fresh instead of continuing
|
||||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||||
const trimmed = xml.trim()
|
const trimmed = xml.trim()
|
||||||
@@ -557,18 +534,10 @@ Start your continuation with the NEXT character after where it stopped.`,
|
|||||||
const finalXml = partialXmlRef.current
|
const finalXml = partialXmlRef.current
|
||||||
partialXmlRef.current = "" // Reset
|
partialXmlRef.current = "" // Reset
|
||||||
|
|
||||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
// It draws now: it takes the stored originals, as display_diagram
|
const validationError = onDisplayChart(fullXml)
|
||||||
const originalXml = prepared.ok ? takeOriginals() : undefined
|
|
||||||
const validationError = prepared.ok
|
|
||||||
? onDisplayChart(prepared.xml, true)
|
|
||||||
: prepared.error
|
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
// Loading failed: back to the diagram before the previews
|
|
||||||
if (prepared.ok && originalXml) {
|
|
||||||
onDisplayChart(originalXml, true)
|
|
||||||
}
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "append_diagram",
|
tool: "append_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
|
|||||||
+35
-124
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
flattenModels,
|
flattenModels,
|
||||||
type ModelConfig,
|
type ModelConfig,
|
||||||
type MultiModelConfig,
|
type MultiModelConfig,
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
@@ -64,28 +62,6 @@ function migrateOldConfig(): MultiModelConfig | null {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
const isKnownProvider = (p: { provider: string }) =>
|
|
||||||
Object.hasOwn(PROVIDER_INFO, p.provider)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored config without providers this version does not know (saved
|
|
||||||
* by another version, or edited by hand): they would break every list of
|
|
||||||
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
|
||||||
*/
|
|
||||||
function parseStoredConfig(stored: string): MultiModelConfig {
|
|
||||||
const config = JSON.parse(stored) as MultiModelConfig
|
|
||||||
const known = config.providers.filter(isKnownProvider)
|
|
||||||
if (known.length < config.providers.length) {
|
|
||||||
console.warn(
|
|
||||||
"Skipped saved providers this version does not know:",
|
|
||||||
config.providers
|
|
||||||
.filter((p) => !isKnownProvider(p))
|
|
||||||
.map((p) => p.provider),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return { ...config, providers: known }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load config from localStorage
|
* Load config from localStorage
|
||||||
*/
|
*/
|
||||||
@@ -96,7 +72,7 @@ function loadConfig(): MultiModelConfig {
|
|||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
return parseStoredConfig(stored)
|
return JSON.parse(stored) as MultiModelConfig
|
||||||
} catch {
|
} catch {
|
||||||
console.error("Failed to parse model config")
|
console.error("Failed to parse model config")
|
||||||
}
|
}
|
||||||
@@ -121,35 +97,7 @@ function loadConfig(): MultiModelConfig {
|
|||||||
*/
|
*/
|
||||||
function saveConfig(config: MultiModelConfig): void {
|
function saveConfig(config: MultiModelConfig): void {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
// Providers this version does not know are not in config: keep them,
|
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||||
// with their keys, for the version that saved them
|
|
||||||
let unknown: MultiModelConfig["providers"] = []
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
||||||
if (stored) {
|
|
||||||
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
|
||||||
(p) => !isKnownProvider(p),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Unreadable: nothing to keep
|
|
||||||
}
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.modelConfigs,
|
|
||||||
JSON.stringify({
|
|
||||||
...config,
|
|
||||||
providers: [...config.providers, ...unknown],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server model to fall back to: the one marked default, else the first one
|
|
||||||
*/
|
|
||||||
function defaultServerModelId(
|
|
||||||
serverModels: FlattenedServerModel[],
|
|
||||||
): string | undefined {
|
|
||||||
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseModelConfigReturn {
|
export interface UseModelConfigReturn {
|
||||||
@@ -195,26 +143,11 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Pick up config changes saved by other tabs, so this tab neither shows a
|
// Load server models on mount (if any)
|
||||||
// stale model nor overwrites their changes on its next save
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorage = (e: StorageEvent) => {
|
|
||||||
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
|
||||||
}
|
|
||||||
window.addEventListener("storage", handleStorage)
|
|
||||||
return () => window.removeEventListener("storage", handleStorage)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Load server models on mount (if any), and again when the desktop app
|
|
||||||
// restarted its server for another preset
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
loadServerModels()
|
|
||||||
return window.electronAPI?.onServerRestarted?.(loadServerModels)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function loadServerModels() {
|
fetch("/api/server-models")
|
||||||
fetch(getApiEndpoint("/api/server-models"))
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -231,37 +164,24 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
setServerModels(raw)
|
setServerModels(raw)
|
||||||
setServerLoaded(true)
|
setServerLoaded(true)
|
||||||
|
|
||||||
// Auto-select the default server model if no model is selected,
|
// Auto-select default server model if no model is currently selected
|
||||||
// or if the saved server model is gone (renamed or removed)
|
|
||||||
setConfig((prev) => {
|
setConfig((prev) => {
|
||||||
const id = prev.selectedModelId
|
if (!prev.selectedModelId && raw.length > 0) {
|
||||||
const isStale =
|
const defaultModel = raw.find((m) => m.isDefault)
|
||||||
id?.startsWith("server:") &&
|
if (defaultModel) {
|
||||||
!raw.some((m) => m.id === id)
|
return { ...prev, selectedModelId: defaultModel.id }
|
||||||
if (id && !isStale) return prev
|
}
|
||||||
// Saved before non-ASCII characters in provider names
|
// If no default marked, use first server model
|
||||||
// got into the id: they were dropped from it
|
return { ...prev, selectedModelId: raw[0].id }
|
||||||
const renamed = raw.filter(
|
}
|
||||||
(m) =>
|
return prev
|
||||||
`server:${m.providerLabel
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
|
|
||||||
)
|
|
||||||
const fallback =
|
|
||||||
renamed.length === 1
|
|
||||||
? renamed[0].id
|
|
||||||
: defaultServerModelId(raw)
|
|
||||||
return fallback === id
|
|
||||||
? prev
|
|
||||||
: { ...prev, selectedModelId: fallback }
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error while loading server models:", error)
|
console.error("Error while loading server models:", error)
|
||||||
setServerLoaded(true)
|
setServerLoaded(true)
|
||||||
})
|
})
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
// Save config whenever it changes (after initial load)
|
// Save config whenever it changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -339,31 +259,24 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteProvider = useCallback(
|
const deleteProvider = useCallback((providerId: string) => {
|
||||||
(providerId: string) => {
|
setConfig((prev) => {
|
||||||
setConfig((prev) => {
|
const provider = prev.providers.find((p) => p.id === providerId)
|
||||||
const provider = prev.providers.find((p) => p.id === providerId)
|
const modelIds = provider?.models.map((m) => m.id) || []
|
||||||
const modelIds = provider?.models.map((m) => m.id) || []
|
|
||||||
|
|
||||||
// Fall back to the default server model if the selected model
|
// Clear selected model if it belongs to deleted provider
|
||||||
// belongs to the deleted provider
|
const newSelectedId =
|
||||||
const newSelectedId =
|
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||||
prev.selectedModelId &&
|
? undefined
|
||||||
modelIds.includes(prev.selectedModelId)
|
: prev.selectedModelId
|
||||||
? defaultServerModelId(serverModels)
|
|
||||||
: prev.selectedModelId
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
providers: prev.providers.filter(
|
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||||
(p) => p.id !== providerId,
|
selectedModelId: newSelectedId,
|
||||||
),
|
}
|
||||||
selectedModelId: newSelectedId,
|
})
|
||||||
}
|
}, [])
|
||||||
})
|
|
||||||
},
|
|
||||||
[serverModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
const addModel = useCallback(
|
const addModel = useCallback(
|
||||||
(providerId: string, modelId: string): ModelConfig => {
|
(providerId: string, modelId: string): ModelConfig => {
|
||||||
@@ -420,15 +333,14 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
// Fall back to the default server model if the selected model
|
// Clear selected model if it was deleted
|
||||||
// was deleted
|
|
||||||
selectedModelId:
|
selectedModelId:
|
||||||
prev.selectedModelId === modelConfigId
|
prev.selectedModelId === modelConfigId
|
||||||
? defaultServerModelId(serverModels)
|
? undefined
|
||||||
: prev.selectedModelId,
|
: prev.selectedModelId,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
[serverModels],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
const resetConfig = useCallback(() => {
|
||||||
@@ -515,8 +427,7 @@ export function getSelectedAIConfig(): {
|
|||||||
|
|
||||||
let config: MultiModelConfig
|
let config: MultiModelConfig
|
||||||
try {
|
try {
|
||||||
// Unknown providers would break the model lookup below
|
config = JSON.parse(stored)
|
||||||
config = parseStoredConfig(stored)
|
|
||||||
} catch {
|
} catch {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-207
@@ -1,8 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import {
|
import {
|
||||||
type ChatSession,
|
type ChatSession,
|
||||||
createEmptySession,
|
createEmptySession,
|
||||||
@@ -13,12 +11,10 @@ import {
|
|||||||
getSession,
|
getSession,
|
||||||
isIndexedDBAvailable,
|
isIndexedDBAvailable,
|
||||||
migrateFromLocalStorage,
|
migrateFromLocalStorage,
|
||||||
readSessionCount,
|
|
||||||
type SessionMetadata,
|
type SessionMetadata,
|
||||||
type StoredMessage,
|
type StoredMessage,
|
||||||
saveSession,
|
saveSession,
|
||||||
} from "@/lib/session-storage"
|
} from "@/lib/session-storage"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
messages: StoredMessage[]
|
messages: StoredMessage[]
|
||||||
@@ -28,13 +24,6 @@ export interface SessionData {
|
|||||||
diagramHistory?: { svg: string; xml: string }[]
|
diagramHistory?: { svg: string; xml: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taken right before a save's data is read: the chat on screen then, and
|
|
||||||
// the order of the reads
|
|
||||||
export interface SaveTicket {
|
|
||||||
generation: number
|
|
||||||
seq: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSessionManagerReturn {
|
export interface UseSessionManagerReturn {
|
||||||
// State
|
// State
|
||||||
sessions: SessionMetadata[]
|
sessions: SessionMetadata[]
|
||||||
@@ -46,27 +35,13 @@ export interface UseSessionManagerReturn {
|
|||||||
// Actions
|
// Actions
|
||||||
switchSession: (id: string) => Promise<SessionData | null>
|
switchSession: (id: string) => Promise<SessionData | null>
|
||||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||||
// ticket: getSaveTicket() before the data was read (by default, now).
|
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||||
// The save is dropped if another chat is on screen when its turn comes,
|
|
||||||
// or if a copy of this chat read later was saved already.
|
|
||||||
// Resolves to false when the save failed (the user was told)
|
|
||||||
saveCurrentSession: (
|
saveCurrentSession: (
|
||||||
data: SessionData,
|
data: SessionData,
|
||||||
ticket?: SaveTicket,
|
forSessionId?: string | null,
|
||||||
) => Promise<boolean>
|
) => Promise<void>
|
||||||
refreshSessions: () => Promise<void>
|
refreshSessions: () => Promise<void>
|
||||||
clearCurrentSession: () => void
|
clearCurrentSession: () => void
|
||||||
getChatGeneration: () => number
|
|
||||||
getSaveTicket: () => SaveTicket
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reading the session list loads every stored session in full, and window
|
|
||||||
// focus also fires each time the user clicks back from the draw.io iframe
|
|
||||||
const FOCUS_REFRESH_INTERVAL_MS = 30_000
|
|
||||||
|
|
||||||
function notifySaveFailed(message: string) {
|
|
||||||
// Same id, so repeated failures update one toast instead of stacking
|
|
||||||
toast.error(message, { id: "session-save-failed", duration: 8000 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSessionManagerOptions {
|
interface UseSessionManagerOptions {
|
||||||
@@ -78,7 +53,6 @@ export function useSessionManager(
|
|||||||
options: UseSessionManagerOptions = {},
|
options: UseSessionManagerOptions = {},
|
||||||
): UseSessionManagerReturn {
|
): UseSessionManagerReturn {
|
||||||
const { initialSessionId } = options
|
const { initialSessionId } = options
|
||||||
const dict = useDictionary()
|
|
||||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -92,23 +66,6 @@ export function useSessionManager(
|
|||||||
const isInitializedRef = useRef(false)
|
const isInitializedRef = useRef(false)
|
||||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||||
const urlChangeSequenceRef = useRef(0)
|
const urlChangeSequenceRef = useRef(0)
|
||||||
// The chat on screen, read by saves that run after a render or a wait
|
|
||||||
const currentSessionRef = useRef<ChatSession | null>(null)
|
|
||||||
// Goes up each time another chat is put on screen (creating the
|
|
||||||
// session of the chat on screen does not count)
|
|
||||||
const chatGenerationRef = useRef(0)
|
|
||||||
// Saves run one at a time, so two saves of a new chat create it once
|
|
||||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
|
|
||||||
// The last ticket number, and that of the newest data saved
|
|
||||||
const saveSeqRef = useRef(0)
|
|
||||||
const savedSeqRef = useRef(0)
|
|
||||||
|
|
||||||
const changeChat = useCallback((session: ChatSession | null) => {
|
|
||||||
chatGenerationRef.current++
|
|
||||||
currentSessionRef.current = session
|
|
||||||
setCurrentSession(session)
|
|
||||||
setCurrentSessionId(session?.id ?? null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Load sessions list
|
// Load sessions list
|
||||||
const refreshSessions = useCallback(async () => {
|
const refreshSessions = useCallback(async () => {
|
||||||
@@ -144,36 +101,14 @@ export function useSessionManager(
|
|||||||
// Load sessions list
|
// Load sessions list
|
||||||
const metadata = await getAllSessionMetadata()
|
const metadata = await getAllSessionMetadata()
|
||||||
setSessions(metadata)
|
setSessions(metadata)
|
||||||
// The desktop app may try its other port next launch, where
|
|
||||||
// an older version may have saved the chats: only when this
|
|
||||||
// origin surely has none (a failed read is not "none") and
|
|
||||||
// keeps no model settings or keys either
|
|
||||||
if (window.electronAPI?.chatsLoaded) {
|
|
||||||
const count = await readSessionCount()
|
|
||||||
// The app saves an empty config on its first load; the
|
|
||||||
// providers are what holds the keys, besides an access
|
|
||||||
// code
|
|
||||||
let hasSettings = true
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
|
||||||
"{}",
|
|
||||||
)
|
|
||||||
hasSettings =
|
|
||||||
(config.providers?.length ?? 0) > 0 ||
|
|
||||||
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
|
||||||
} catch {
|
|
||||||
// Unreadable: treat as settings, and stay
|
|
||||||
}
|
|
||||||
if (count !== null && !hasSettings) {
|
|
||||||
window.electronAPI.chatsLoaded(count).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only load a session if initialSessionId is provided (from URL param)
|
// Only load a session if initialSessionId is provided (from URL param)
|
||||||
if (initialSessionId) {
|
if (initialSessionId) {
|
||||||
const session = await getSession(initialSessionId)
|
const session = await getSession(initialSessionId)
|
||||||
if (session) changeChat(session)
|
if (session) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
}
|
||||||
// If session not found, stay in blank state (URL has invalid session ID)
|
// If session not found, stay in blank state (URL has invalid session ID)
|
||||||
}
|
}
|
||||||
// If no initialSessionId, start with blank state (no auto-restore)
|
// If no initialSessionId, start with blank state (no auto-restore)
|
||||||
@@ -185,7 +120,7 @@ export function useSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
}, [initialSessionId, changeChat])
|
}, [initialSessionId])
|
||||||
|
|
||||||
// Handle URL session ID changes after initialization
|
// Handle URL session ID changes after initialization
|
||||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||||
@@ -200,7 +135,6 @@ export function useSessionManager(
|
|||||||
|
|
||||||
async function handleSessionIdChange() {
|
async function handleSessionIdChange() {
|
||||||
if (initialSessionId) {
|
if (initialSessionId) {
|
||||||
const generation = chatGenerationRef.current
|
|
||||||
// URL has session ID - load it
|
// URL has session ID - load it
|
||||||
const session = await getSession(initialSessionId)
|
const session = await getSession(initialSessionId)
|
||||||
|
|
||||||
@@ -209,13 +143,16 @@ export function useSessionManager(
|
|||||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Another chat was put on screen meanwhile (New Chat right
|
|
||||||
// after this one got its session id in the URL): keep it
|
|
||||||
if (generation !== chatGenerationRef.current) return
|
|
||||||
|
|
||||||
// Only update if the session is different from current
|
if (session) {
|
||||||
if (session && currentSessionRef.current?.id !== session.id) {
|
// Only update if the session is different from current
|
||||||
changeChat(session)
|
setCurrentSessionId((current) => {
|
||||||
|
if (current !== session.id) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Removed: else clause that clears session
|
// Removed: else clause that clears session
|
||||||
@@ -224,17 +161,11 @@ export function useSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleSessionIdChange()
|
handleSessionIdChange()
|
||||||
}, [initialSessionId, isAvailable, changeChat])
|
}, [initialSessionId, isAvailable])
|
||||||
|
|
||||||
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
// Refresh sessions on window focus (multi-tab sync)
|
||||||
const lastFocusRefreshRef = useRef(0)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
const now = Date.now()
|
|
||||||
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastFocusRefreshRef.current = now
|
|
||||||
refreshSessions()
|
refreshSessions()
|
||||||
}
|
}
|
||||||
window.addEventListener("focus", handleFocus)
|
window.addEventListener("focus", handleFocus)
|
||||||
@@ -246,11 +177,9 @@ export function useSessionManager(
|
|||||||
async (id: string): Promise<SessionData | null> => {
|
async (id: string): Promise<SessionData | null> => {
|
||||||
if (id === currentSessionId) return null
|
if (id === currentSessionId) return null
|
||||||
|
|
||||||
// Save current session first if it has messages (as saved
|
// Save current session first if it has messages
|
||||||
// last: the caller may have just saved it)
|
if (currentSession && currentSession.messages.length > 0) {
|
||||||
const current = currentSessionRef.current
|
await saveSession(currentSession)
|
||||||
if (current && current.messages.length > 0) {
|
|
||||||
await saveSession(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the target session
|
// Load the target session
|
||||||
@@ -260,7 +189,9 @@ export function useSessionManager(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
changeChat(session)
|
// Update state
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
@@ -270,7 +201,7 @@ export function useSessionManager(
|
|||||||
diagramHistory: session.diagramHistory,
|
diagramHistory: session.diagramHistory,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentSessionId, changeChat],
|
[currentSessionId, currentSession],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delete a session
|
// Delete a session
|
||||||
@@ -280,137 +211,101 @@ export function useSessionManager(
|
|||||||
await deleteSessionFromDB(id)
|
await deleteSessionFromDB(id)
|
||||||
|
|
||||||
// If deleting current session, clear state (caller will show new empty session)
|
// If deleting current session, clear state (caller will show new empty session)
|
||||||
if (wasCurrentSession) changeChat(null)
|
if (wasCurrentSession) {
|
||||||
|
setCurrentSession(null)
|
||||||
|
setCurrentSessionId(null)
|
||||||
|
}
|
||||||
|
|
||||||
await refreshSessions()
|
await refreshSessions()
|
||||||
|
|
||||||
return { wasCurrentSession }
|
return { wasCurrentSession }
|
||||||
},
|
},
|
||||||
[currentSessionId, refreshSessions, changeChat],
|
[currentSessionId, refreshSessions],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save current session data (debounced externally by caller)
|
// Save current session data (debounced externally by caller)
|
||||||
|
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||||
const saveCurrentSession = useCallback(
|
const saveCurrentSession = useCallback(
|
||||||
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
async (
|
||||||
// The data is of the chat on screen when it was read
|
data: SessionData,
|
||||||
const { generation, seq } = ticket ?? {
|
forSessionId?: string | null,
|
||||||
generation: chatGenerationRef.current,
|
): Promise<void> => {
|
||||||
seq: ++saveSeqRef.current,
|
// 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
|
||||||
}
|
}
|
||||||
const run = async (): Promise<boolean> => {
|
|
||||||
// That chat is no longer on screen (leaving it saved it)
|
|
||||||
if (generation !== chatGenerationRef.current) return true
|
|
||||||
// A copy read later was saved already (one that waited for
|
|
||||||
// its thumbnail must not undo it)
|
|
||||||
if (seq < savedSeqRef.current) return true
|
|
||||||
// Nothing can be stored without IndexedDB
|
|
||||||
if (!isIndexedDBAvailable()) return true
|
|
||||||
// The user may put another chat on screen while this one is
|
|
||||||
// written; the stored copy is still right, the state is not
|
|
||||||
const stillOnScreen = () =>
|
|
||||||
chatGenerationRef.current === generation
|
|
||||||
const currentSession = currentSessionRef.current
|
|
||||||
|
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
// Create a new session if none exists
|
// Create a new session if none exists
|
||||||
const newSession: ChatSession = {
|
const newSession: ChatSession = {
|
||||||
...createEmptySession(),
|
...createEmptySession(),
|
||||||
messages: data.messages,
|
|
||||||
xmlSnapshots: data.xmlSnapshots,
|
|
||||||
diagramXml: data.diagramXml,
|
|
||||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
|
||||||
diagramHistory: data.diagramHistory,
|
|
||||||
title: extractTitle(data.messages),
|
|
||||||
}
|
|
||||||
// Without a stored session, keep no session id (it would end
|
|
||||||
// up in the URL and point to nothing after a reload)
|
|
||||||
if (!(await saveSession(newSession))) {
|
|
||||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
savedSeqRef.current = seq
|
|
||||||
await enforceSessionLimit()
|
|
||||||
if (stillOnScreen()) {
|
|
||||||
currentSessionRef.current = newSession
|
|
||||||
setCurrentSession(newSession)
|
|
||||||
setCurrentSessionId(newSession.id)
|
|
||||||
}
|
|
||||||
await refreshSessions()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing session
|
|
||||||
const updatedSession: ChatSession = {
|
|
||||||
...currentSession,
|
|
||||||
messages: data.messages,
|
messages: data.messages,
|
||||||
xmlSnapshots: data.xmlSnapshots,
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
diagramXml: data.diagramXml,
|
diagramXml: data.diagramXml,
|
||||||
thumbnailDataUrl:
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||||
data.thumbnailDataUrl ??
|
diagramHistory: data.diagramHistory,
|
||||||
currentSession.thumbnailDataUrl,
|
title: extractTitle(data.messages),
|
||||||
diagramHistory:
|
|
||||||
data.diagramHistory ?? currentSession.diagramHistory,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
// Update title if it's still default and we have messages
|
|
||||||
title:
|
|
||||||
currentSession.title === "New Chat" &&
|
|
||||||
data.messages.length > 0
|
|
||||||
? extractTitle(data.messages)
|
|
||||||
: currentSession.title,
|
|
||||||
}
|
}
|
||||||
|
await saveSession(newSession)
|
||||||
if (!(await saveSession(updatedSession))) {
|
await enforceSessionLimit()
|
||||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
setCurrentSession(newSession)
|
||||||
return false
|
setCurrentSessionId(newSession.id)
|
||||||
}
|
await refreshSessions()
|
||||||
savedSeqRef.current = seq
|
return
|
||||||
if (stillOnScreen()) {
|
|
||||||
currentSessionRef.current = updatedSession
|
|
||||||
setCurrentSession(updatedSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sessions list metadata
|
|
||||||
setSessions((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
s.id === updatedSession.id
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
title: updatedSession.title,
|
|
||||||
updatedAt: updatedSession.updatedAt,
|
|
||||||
messageCount: updatedSession.messages.length,
|
|
||||||
hasDiagram:
|
|
||||||
!!updatedSession.diagramXml &&
|
|
||||||
updatedSession.diagramXml.trim().length >
|
|
||||||
0,
|
|
||||||
thumbnailDataUrl:
|
|
||||||
updatedSession.thumbnailDataUrl,
|
|
||||||
}
|
|
||||||
: s,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
const result = saveQueueRef.current.then(run)
|
|
||||||
saveQueueRef.current = result.catch(() => {})
|
// Update existing session
|
||||||
return result
|
const updatedSession: ChatSession = {
|
||||||
|
...currentSession,
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl:
|
||||||
|
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||||
|
diagramHistory:
|
||||||
|
data.diagramHistory ?? currentSession.diagramHistory,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
// Update title if it's still default and we have messages
|
||||||
|
title:
|
||||||
|
currentSession.title === "New Chat" &&
|
||||||
|
data.messages.length > 0
|
||||||
|
? extractTitle(data.messages)
|
||||||
|
: currentSession.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSession(updatedSession)
|
||||||
|
setCurrentSession(updatedSession)
|
||||||
|
|
||||||
|
// Update sessions list metadata
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === updatedSession.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
title: updatedSession.title,
|
||||||
|
updatedAt: updatedSession.updatedAt,
|
||||||
|
messageCount: updatedSession.messages.length,
|
||||||
|
hasDiagram:
|
||||||
|
!!updatedSession.diagramXml &&
|
||||||
|
updatedSession.diagramXml.trim().length > 0,
|
||||||
|
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[refreshSessions, dict],
|
[currentSession, currentSessionId, refreshSessions],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clear current session state (for starting fresh without loading another session)
|
// Clear current session state (for starting fresh without loading another session)
|
||||||
const clearCurrentSession = useCallback(() => {
|
const clearCurrentSession = useCallback(() => {
|
||||||
changeChat(null)
|
setCurrentSession(null)
|
||||||
}, [changeChat])
|
setCurrentSessionId(null)
|
||||||
|
}, [])
|
||||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
|
||||||
|
|
||||||
const getSaveTicket = useCallback(
|
|
||||||
(): SaveTicket => ({
|
|
||||||
generation: chatGenerationRef.current,
|
|
||||||
seq: ++saveSeqRef.current,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -423,7 +318,5 @@ export function useSessionManager(
|
|||||||
saveCurrentSession,
|
saveCurrentSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
clearCurrentSession,
|
clearCurrentSession,
|
||||||
getChatGeneration,
|
|
||||||
getSaveTicket,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
||||||
import { useCallback, useRef } from "react"
|
import { useCallback, useRef } from "react"
|
||||||
import { getSelectedAIConfig } from "@/hooks/use-model-config"
|
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
type ValidationResult,
|
type ValidationResult,
|
||||||
@@ -40,8 +39,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
const { object, submit, isLoading, error, stop } = useObject({
|
const { object, submit, isLoading, error, stop } = useObject({
|
||||||
api: getApiEndpoint("/api/validate-diagram"),
|
api: getApiEndpoint("/api/validate-diagram"),
|
||||||
schema: ValidationResultSchema,
|
schema: ValidationResultSchema,
|
||||||
// Resolved per request so a changed access code is picked up
|
|
||||||
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
|
|
||||||
onFinish: ({
|
onFinish: ({
|
||||||
object,
|
object,
|
||||||
error: finishError,
|
error: finishError,
|
||||||
@@ -103,22 +100,9 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
[submit],
|
[submit],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* End a running check (the user pressed Stop): its promise rejects with
|
|
||||||
* an AbortError, so the tool handler can finish at once.
|
|
||||||
*/
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
const pending = pendingValidationRef.current
|
|
||||||
if (!pending) return
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
stop()
|
|
||||||
pending.reject(new DOMException("Validation cancelled", "AbortError"))
|
|
||||||
}, [stop])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate with fallback - returns default valid result on error.
|
* Validate with fallback - returns default valid result on error.
|
||||||
* Use this to avoid blocking the user on validation failures.
|
* Use this to avoid blocking the user on validation failures.
|
||||||
* A cancelled check is passed on as its AbortError.
|
|
||||||
*/
|
*/
|
||||||
const validateWithFallback = useCallback(
|
const validateWithFallback = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -128,7 +112,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
return await validate(imageData, sessionId)
|
return await validate(imageData, sessionId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error)?.name === "AbortError") throw error
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"[useValidateDiagram] Validation failed, using fallback:",
|
"[useValidateDiagram] Validation failed, using fallback:",
|
||||||
error,
|
error,
|
||||||
@@ -144,7 +127,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
validate,
|
validate,
|
||||||
validateWithFallback,
|
validateWithFallback,
|
||||||
stop,
|
stop,
|
||||||
cancel,
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
isValidating: isLoading,
|
isValidating: isLoading,
|
||||||
|
|||||||
+1
-11
@@ -1,17 +1,7 @@
|
|||||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||||
|
|
||||||
export async function register() {
|
export function register() {
|
||||||
// Overlay admin settings file onto process.env before anything reads config
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
try {
|
|
||||||
const { applyToEnv } = await import("@/lib/admin/settings")
|
|
||||||
applyToEnv()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[admin-settings] Failed to apply settings:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip telemetry if Langfuse env vars are not configured
|
// Skip telemetry if Langfuse env vars are not configured
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* Refuse a POST that a page on another website could have sent. A browser
|
|
||||||
* sends a cross-site POST without asking first (CORS preflight) only with a
|
|
||||||
* text or form body, so the routes take JSON only. In the desktop app also
|
|
||||||
* refuse a foreign Host: a site that points its own domain name at
|
|
||||||
* 127.0.0.1 (DNS rebinding) is same-origin with the local server, but its
|
|
||||||
* requests carry that domain. A request the server builds itself has no
|
|
||||||
* Host. Returns the response to send, or null when the request may go on.
|
|
||||||
*/
|
|
||||||
export function rejectCrossSite(req: Request): Response | null {
|
|
||||||
const contentType = req.headers.get("content-type") ?? ""
|
|
||||||
if (!/^\s*application\/json\b/i.test(contentType)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Content-Type must be application/json" },
|
|
||||||
{ status: 415 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const host = req.headers.get("host")
|
|
||||||
if (
|
|
||||||
process.env.NEXT_AI_DRAWIO_DESKTOP === "1" &&
|
|
||||||
host &&
|
|
||||||
!/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host)
|
|
||||||
) {
|
|
||||||
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the x-access-code header against ACCESS_CODE_LIST.
|
|
||||||
* Returns a 401 response to send back when the check fails, or null when the
|
|
||||||
* request may continue (including when no access codes are configured).
|
|
||||||
*/
|
|
||||||
export function checkAccessCode(req: Request): Response | null {
|
|
||||||
const accessCodes =
|
|
||||||
process.env.ACCESS_CODE_LIST?.split(",")
|
|
||||||
.map((code) => code.trim())
|
|
||||||
.filter(Boolean) || []
|
|
||||||
if (accessCodes.length === 0) return null
|
|
||||||
|
|
||||||
const accessCodeHeader = req.headers.get("x-access-code")
|
|
||||||
if (accessCodeHeader && accessCodes.includes(accessCodeHeader)) return null
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { timingSafeEqual } from "crypto"
|
|
||||||
|
|
||||||
// Shared auth for admin API routes: compares x-admin-password header
|
|
||||||
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
|
|
||||||
export function checkAdminAuth(req: Request): Response | null {
|
|
||||||
const password = process.env.ADMIN_PASSWORD
|
|
||||||
if (!password) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const provided = req.headers.get("x-admin-password") || ""
|
|
||||||
const a = Buffer.from(provided)
|
|
||||||
const b = Buffer.from(password)
|
|
||||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid admin password" },
|
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaskedSecret {
|
|
||||||
isSet: true
|
|
||||||
hint: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskSecret(value: string): MaskedSecret {
|
|
||||||
return {
|
|
||||||
isSet: true,
|
|
||||||
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
import {
|
|
||||||
ProviderNameSchema,
|
|
||||||
type ServerModelsConfig,
|
|
||||||
slugify,
|
|
||||||
} from "@/lib/server-model-config"
|
|
||||||
import {
|
|
||||||
FIXED_CRED_PROVIDERS,
|
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderName,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { type MaskedSecret, maskSecret } from "./auth"
|
|
||||||
import { loadSettings } from "./settings"
|
|
||||||
|
|
||||||
// Admin-configured providers, mirroring the user ModelConfigDialog's data
|
|
||||||
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
|
|
||||||
//
|
|
||||||
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
|
|
||||||
// loadRawServerModelsConfig() merges the env baseline with the panel's
|
|
||||||
// providers at read time, so .env stays authoritative for its own entries.
|
|
||||||
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
|
|
||||||
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
|
|
||||||
// OPENAI_API_KEY that env-based entries may rely on.
|
|
||||||
|
|
||||||
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
|
|
||||||
|
|
||||||
// A secret field in transit: plaintext string (new value) or an
|
|
||||||
// {isSet} marker meaning "keep the stored value".
|
|
||||||
const SecretInputSchema = z
|
|
||||||
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
|
|
||||||
.optional()
|
|
||||||
|
|
||||||
export const AdminProviderSchema = z.object({
|
|
||||||
id: z.string().min(1),
|
|
||||||
provider: ProviderNameSchema,
|
|
||||||
name: z.string().optional(),
|
|
||||||
apiKey: SecretInputSchema,
|
|
||||||
baseUrl: z.string().optional(),
|
|
||||||
awsAccessKeyId: SecretInputSchema,
|
|
||||||
awsSecretAccessKey: SecretInputSchema,
|
|
||||||
awsRegion: z.string().optional(),
|
|
||||||
vertexApiKey: SecretInputSchema,
|
|
||||||
models: z.array(z.string().min(1)),
|
|
||||||
isDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const AdminProvidersSchema = z.array(AdminProviderSchema)
|
|
||||||
|
|
||||||
// Stored shape: secrets are plain strings (never {isSet} markers, which
|
|
||||||
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
|
|
||||||
// hand-edited/corrupted value can't slip a marker object past maskSecret.
|
|
||||||
const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
|
||||||
apiKey: z.string().optional(),
|
|
||||||
awsAccessKeyId: z.string().optional(),
|
|
||||||
awsSecretAccessKey: z.string().optional(),
|
|
||||||
vertexApiKey: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
|
||||||
|
|
||||||
// Stored form: secrets are plain strings
|
|
||||||
export interface StoredAdminProvider {
|
|
||||||
id: string
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string
|
|
||||||
apiKey?: string
|
|
||||||
baseUrl?: string
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
vertexApiKey?: string
|
|
||||||
models: string[]
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET_FIELDS = [
|
|
||||||
"apiKey",
|
|
||||||
"awsAccessKeyId",
|
|
||||||
"awsSecretAccessKey",
|
|
||||||
"vertexApiKey",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
|
|
||||||
function credEnvNames(
|
|
||||||
provider: ProviderName,
|
|
||||||
index: number,
|
|
||||||
): { key?: string; url?: string } {
|
|
||||||
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const prefix =
|
|
||||||
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
|
|
||||||
const suffix = index === 0 ? "" : `_${index + 1}`
|
|
||||||
return {
|
|
||||||
key: `ADMIN_${prefix}_API_KEY${suffix}`,
|
|
||||||
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAdminProviders(): StoredAdminProvider[] {
|
|
||||||
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
|
|
||||||
if (!raw) return []
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
// Validate each entry's shape — a malformed/hand-edited value must
|
|
||||||
// not reach runtime code that assumes provider/models exist.
|
|
||||||
return parsed.flatMap((entry) => {
|
|
||||||
const result = StoredAdminProviderSchema.safeParse(entry)
|
|
||||||
return result.success ? [result.data as StoredAdminProvider] : []
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
console.error("[admin-providers] Failed to parse stored providers")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MaskedAdminProvider = Omit<
|
|
||||||
StoredAdminProvider,
|
|
||||||
(typeof SECRET_FIELDS)[number]
|
|
||||||
> & {
|
|
||||||
apiKey?: MaskedSecret
|
|
||||||
awsAccessKeyId?: MaskedSecret
|
|
||||||
awsSecretAccessKey?: MaskedSecret
|
|
||||||
vertexApiKey?: MaskedSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskAdminProviders(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
): MaskedAdminProvider[] {
|
|
||||||
return list.map((p) => {
|
|
||||||
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
|
|
||||||
for (const field of SECRET_FIELDS) {
|
|
||||||
const value = p[field]
|
|
||||||
masked[field] = value ? maskSecret(value) : undefined
|
|
||||||
}
|
|
||||||
return masked
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve {isSet} markers in incoming secrets against the stored list
|
|
||||||
export function mergeSecrets(
|
|
||||||
incoming: AdminProviderInput[],
|
|
||||||
stored: StoredAdminProvider[],
|
|
||||||
): StoredAdminProvider[] {
|
|
||||||
const storedById = new Map(stored.map((p) => [p.id, p]))
|
|
||||||
return incoming.map((p) => {
|
|
||||||
const prev = storedById.get(p.id)
|
|
||||||
const merged = { ...p } as StoredAdminProvider
|
|
||||||
for (const field of SECRET_FIELDS) {
|
|
||||||
const value = p[field]
|
|
||||||
if (typeof value === "string") {
|
|
||||||
merged[field] = value || undefined
|
|
||||||
} else if (value?.isSet) {
|
|
||||||
merged[field] = prev?.[field]
|
|
||||||
} else {
|
|
||||||
merged[field] = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayName(p: StoredAdminProvider): string {
|
|
||||||
return p.name?.trim() || PROVIDER_INFO[p.provider].label
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateAdminProviders(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
envConfig: ServerModelsConfig | null = null,
|
|
||||||
): string | null {
|
|
||||||
const envProviders = envConfig?.providers ?? []
|
|
||||||
for (const single of FIXED_CRED_PROVIDERS) {
|
|
||||||
if (list.filter((p) => p.provider === single).length > 1) {
|
|
||||||
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
|
|
||||||
}
|
|
||||||
// Its credentials are global; a panel instance would silently
|
|
||||||
// override the credentials env-configured models rely on
|
|
||||||
if (
|
|
||||||
list.some((p) => p.provider === single) &&
|
|
||||||
envProviders.some((p) => p.provider === single)
|
|
||||||
) {
|
|
||||||
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Server model ids are built from the slugified name, so names must
|
|
||||||
// stay distinct after slugifying ("OpenAI" and "openai" would collide)
|
|
||||||
const names = list.map((p) => displayName(p))
|
|
||||||
const slugs = names.map(slugify)
|
|
||||||
if (new Set(slugs).size !== slugs.length) {
|
|
||||||
return "Provider display names must be unique (ignoring case and punctuation)."
|
|
||||||
}
|
|
||||||
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
|
|
||||||
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
|
|
||||||
if (clash) {
|
|
||||||
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
|
|
||||||
}
|
|
||||||
if (list.filter((p) => p.isDefault).length > 1) {
|
|
||||||
return "Only one provider can be the default."
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// The panel's contribution to the server models config, derived at read
|
|
||||||
// time and merged with the env baseline by loadRawServerModelsConfig().
|
|
||||||
export function adminProvidersToConfig(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
): ServerModelsConfig {
|
|
||||||
const config: ServerModelsConfig = { providers: [] }
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
if (p.models.length === 0) continue
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
config.providers.push({
|
|
||||||
name: displayName(p),
|
|
||||||
provider: p.provider,
|
|
||||||
models: p.models,
|
|
||||||
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
|
|
||||||
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
|
|
||||||
...(p.isDefault ? { default: true } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings updates derived from the provider list: credential env vars,
|
|
||||||
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
|
|
||||||
// Keys derived from `previous` but absent now are set to null (removed,
|
|
||||||
// falling back to the environment).
|
|
||||||
export function deriveEnvUpdates(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
previous: StoredAdminProvider[],
|
|
||||||
): Record<string, string | null> {
|
|
||||||
const updates: Record<string, string | null> = {}
|
|
||||||
|
|
||||||
// Clear everything the previous list owned, then overwrite below
|
|
||||||
for (const key of derivedEnvKeys(previous)) updates[key] = null
|
|
||||||
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
|
|
||||||
if (p.provider === "bedrock") {
|
|
||||||
// ADMIN_ names keep the standard AWS_* vars untouched, so other
|
|
||||||
// AWS clients (e.g. the DynamoDB quota table) keep their own
|
|
||||||
// credentials instead of picking up the panel's Bedrock keys
|
|
||||||
if (p.awsAccessKeyId)
|
|
||||||
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
|
||||||
if (p.awsSecretAccessKey)
|
|
||||||
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
|
||||||
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
|
|
||||||
} else if (p.provider === "vertexai") {
|
|
||||||
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
|
|
||||||
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
|
||||||
} else if (p.provider === "ollama") {
|
|
||||||
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
|
|
||||||
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
|
|
||||||
} else {
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
if (env.key && p.apiKey) updates[env.key] = p.apiKey
|
|
||||||
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
|
|
||||||
|
|
||||||
// The panel's default also becomes the server-wide default model;
|
|
||||||
// without one, the env-configured default applies.
|
|
||||||
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
|
|
||||||
if (defaultEntry) {
|
|
||||||
updates.AI_PROVIDER = defaultEntry.provider
|
|
||||||
updates.AI_MODEL = defaultEntry.models[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return updates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every settings key the panel may have written for a given list.
|
|
||||||
// AI_MODELS_CONFIG is included to clean up values written by older
|
|
||||||
// versions of the panel (it is no longer written).
|
|
||||||
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
|
||||||
const keys = new Set<string>([
|
|
||||||
"AI_MODELS_CONFIG",
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
])
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
if (p.provider === "bedrock") {
|
|
||||||
keys.add("ADMIN_AWS_ACCESS_KEY_ID")
|
|
||||||
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
|
|
||||||
keys.add("ADMIN_AWS_REGION")
|
|
||||||
// Written by older versions; listed so the next save clears them
|
|
||||||
keys.add("AWS_ACCESS_KEY_ID")
|
|
||||||
keys.add("AWS_SECRET_ACCESS_KEY")
|
|
||||||
keys.add("AWS_REGION")
|
|
||||||
} else if (p.provider === "vertexai") {
|
|
||||||
keys.add("GOOGLE_VERTEX_API_KEY")
|
|
||||||
keys.add("GOOGLE_VERTEX_BASE_URL")
|
|
||||||
} else if (p.provider === "ollama") {
|
|
||||||
keys.add("OLLAMA_API_KEY")
|
|
||||||
keys.add("OLLAMA_BASE_URL")
|
|
||||||
} else {
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
if (env.key) keys.add(env.key)
|
|
||||||
if (env.url) keys.add(env.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...keys]
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
// Declarative registry of the general env vars editable in the admin panel.
|
|
||||||
// Drives both server-side validation (app/api/admin/settings) and UI
|
|
||||||
// rendering (app/[lang]/admin). Keys are exactly the env var names.
|
|
||||||
//
|
|
||||||
// AI providers and models are managed separately in the panel's Models
|
|
||||||
// section (lib/admin/providers.ts), not here.
|
|
||||||
//
|
|
||||||
// Not listed here (and therefore rejected by the API):
|
|
||||||
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
|
|
||||||
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
|
||||||
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
|
||||||
|
|
||||||
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
|
||||||
|
|
||||||
export interface SettingDef {
|
|
||||||
key: string
|
|
||||||
group: string
|
|
||||||
type: SettingType
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
options?: string[]
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
placeholder?: string
|
|
||||||
// Built-in default applied at runtime when the value is unset, so the UI
|
|
||||||
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
|
|
||||||
default?: string
|
|
||||||
// Value is only picked up at process start (module-load readers)
|
|
||||||
restartRequired?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingGroup {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
// Optional sections gated by an on/off switch in the panel; fields are
|
|
||||||
// grayed out until enabled. Starts on when any field is already set.
|
|
||||||
toggleable?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SETTING_GROUPS: SettingGroup[] = [
|
|
||||||
{
|
|
||||||
id: "generation",
|
|
||||||
title: "Generation",
|
|
||||||
description: "Output parameters applied to all chat requests.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "access",
|
|
||||||
title: "Access Control",
|
|
||||||
description: "Restrict who can use this deployment.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "features",
|
|
||||||
title: "Features",
|
|
||||||
description: "Optional features and security toggles.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "observability",
|
|
||||||
title: "Observability",
|
|
||||||
description: "Langfuse tracing for LLM calls.",
|
|
||||||
toggleable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quota",
|
|
||||||
title: "Quota & Rate Limits",
|
|
||||||
description:
|
|
||||||
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
|
|
||||||
toggleable: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SETTINGS_REGISTRY: SettingDef[] = [
|
|
||||||
// ── Generation ───────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "TEMPERATURE",
|
|
||||||
group: "generation",
|
|
||||||
type: "number",
|
|
||||||
label: "Temperature",
|
|
||||||
description:
|
|
||||||
"Leave unset for reasoning models that reject temperature.",
|
|
||||||
min: 0,
|
|
||||||
max: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "MAX_OUTPUT_TOKENS",
|
|
||||||
group: "generation",
|
|
||||||
type: "number",
|
|
||||||
label: "Max Output Tokens",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Access Control ───────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "ACCESS_CODE_LIST",
|
|
||||||
group: "access",
|
|
||||||
type: "string",
|
|
||||||
label: "Access Codes",
|
|
||||||
description:
|
|
||||||
"Comma-separated list. Users must enter one to chat. Empty = open access.",
|
|
||||||
placeholder: "code1,code2",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Features ─────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "ENABLE_VLM_VALIDATION",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "VLM Diagram Validation",
|
|
||||||
description:
|
|
||||||
"Visually validate generated diagrams with a vision model.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "VALIDATION_MODEL",
|
|
||||||
group: "features",
|
|
||||||
type: "string",
|
|
||||||
label: "Validation Model",
|
|
||||||
description: "Falls back to the default AI model when empty.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "VALIDATION_TIMEOUT",
|
|
||||||
group: "features",
|
|
||||||
type: "number",
|
|
||||||
label: "Validation Timeout (ms)",
|
|
||||||
min: 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ENABLE_HISTORY_XML_REPLACE",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "History XML Compression",
|
|
||||||
description: "Replace old diagram XML in history with placeholders.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ALLOW_PRIVATE_URLS",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "Allow Private URLs",
|
|
||||||
description:
|
|
||||||
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
|
|
||||||
// Unset means allowed at runtime (ssrf-protection: !== "false")
|
|
||||||
default: "true",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Observability ────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_PUBLIC_KEY",
|
|
||||||
group: "observability",
|
|
||||||
type: "string",
|
|
||||||
label: "Langfuse Public Key",
|
|
||||||
placeholder: "pk-lf-…",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_SECRET_KEY",
|
|
||||||
group: "observability",
|
|
||||||
type: "secret",
|
|
||||||
label: "Langfuse Secret Key",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_BASEURL",
|
|
||||||
group: "observability",
|
|
||||||
type: "string",
|
|
||||||
label: "Langfuse Base URL",
|
|
||||||
placeholder: "https://cloud.langfuse.com",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Quota ────────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "DAILY_REQUEST_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Daily Request Limit",
|
|
||||||
description: "Per IP per day.",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DAILY_TOKEN_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Daily Token Limit",
|
|
||||||
description: "Per IP per day.",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "TPM_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Tokens Per Minute",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DYNAMODB_QUOTA_TABLE",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "DynamoDB Table",
|
|
||||||
description: "Quota enforcement is disabled when empty.",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DYNAMODB_REGION",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "DynamoDB Region",
|
|
||||||
placeholder: "ap-northeast-1",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "QUOTA_TIMEZONE",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "Quota Timezone",
|
|
||||||
description: "Timezone for the daily reset boundary.",
|
|
||||||
placeholder: "UTC",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
|
|
||||||
SETTINGS_REGISTRY.map((def) => [def.key, def]),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
|
|
||||||
SETTING_GROUPS.map((g) => [
|
|
||||||
g.id,
|
|
||||||
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
// File-based admin settings, overlaid onto process.env (dotenv-style).
|
|
||||||
// Precedence: settings file > env var > built-in default.
|
|
||||||
// Keys are exactly the env var names.
|
|
||||||
|
|
||||||
interface SettingsFile {
|
|
||||||
version: 1
|
|
||||||
values: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsState {
|
|
||||||
// Original env values snapshotted before the first overlay, so removing
|
|
||||||
// a key from the settings file restores the env default. null = was unset.
|
|
||||||
originalEnv: Record<string, string | null>
|
|
||||||
// Keys currently overlaid, so we can restore ones removed from the file.
|
|
||||||
overlaidKeys: Set<string>
|
|
||||||
cachedSettings: Record<string, string> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kept on globalThis because the build can load this module more than once
|
|
||||||
// (instrumentation.ts and the API routes get separate copies); per-module
|
|
||||||
// state would make a route forget what instrumentation overlaid at startup.
|
|
||||||
const globalState = globalThis as typeof globalThis & {
|
|
||||||
__adminSettingsState?: SettingsState
|
|
||||||
}
|
|
||||||
globalState.__adminSettingsState ??= {
|
|
||||||
originalEnv: {},
|
|
||||||
overlaidKeys: new Set(),
|
|
||||||
cachedSettings: null,
|
|
||||||
}
|
|
||||||
const state = globalState.__adminSettingsState
|
|
||||||
|
|
||||||
export function getSettingsPath(): string {
|
|
||||||
const custom = process.env.SETTINGS_FILE
|
|
||||||
if (custom && custom.trim().length > 0) return custom
|
|
||||||
return path.join(process.cwd(), "data", "settings.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadSettings(): Record<string, string> {
|
|
||||||
if (state.cachedSettings) return state.cachedSettings
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(getSettingsPath(), "utf8")
|
|
||||||
const parsed = JSON.parse(raw) as SettingsFile
|
|
||||||
// Keep only string values — a hand-edited or corrupted file could
|
|
||||||
// hold null/arrays/numbers that would otherwise be overlaid onto
|
|
||||||
// process.env and coerce to junk like "[object Object]".
|
|
||||||
const values: Record<string, string> = {}
|
|
||||||
const rawValues =
|
|
||||||
parsed &&
|
|
||||||
typeof parsed.values === "object" &&
|
|
||||||
parsed.values &&
|
|
||||||
!Array.isArray(parsed.values)
|
|
||||||
? parsed.values
|
|
||||||
: {}
|
|
||||||
for (const [key, value] of Object.entries(rawValues)) {
|
|
||||||
if (typeof value === "string") values[key] = value
|
|
||||||
}
|
|
||||||
state.cachedSettings = values
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code !== "ENOENT") {
|
|
||||||
console.error("[admin-settings] Failed to read settings file:", err)
|
|
||||||
}
|
|
||||||
state.cachedSettings = {}
|
|
||||||
}
|
|
||||||
return state.cachedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyToEnv(): void {
|
|
||||||
const values = loadSettings()
|
|
||||||
const { originalEnv } = state
|
|
||||||
|
|
||||||
// Restore env for keys that were overlaid before but are now gone
|
|
||||||
for (const key of state.overlaidKeys) {
|
|
||||||
if (!(key in values)) {
|
|
||||||
const original = originalEnv[key]
|
|
||||||
if (original === null) delete process.env[key]
|
|
||||||
else process.env[key] = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (!(key in originalEnv)) {
|
|
||||||
originalEnv[key] = process.env[key] ?? null
|
|
||||||
}
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
state.overlaidKeys = new Set(Object.keys(values))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The effective env value if the file entry were removed (for fallback display)
|
|
||||||
export function getEnvFallback(key: string): string | null {
|
|
||||||
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
|
|
||||||
return process.env[key] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether a key's current value comes from the file, the environment, or is unset
|
|
||||||
export function getValueSource(key: string): "file" | "env" | "default" {
|
|
||||||
if (key in loadSettings()) return "file"
|
|
||||||
return getEnvFallback(key) !== null ? "env" : "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSettings(updates: Record<string, string | null>): void {
|
|
||||||
const current = { ...loadSettings() }
|
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
|
||||||
if (value === null) delete current[key]
|
|
||||||
else current[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getSettingsPath()
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
||||||
const tmpPath = `${filePath}.tmp`
|
|
||||||
const data: SettingsFile = { version: 1, values: current }
|
|
||||||
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
|
|
||||||
fs.renameSync(tmpPath, filePath)
|
|
||||||
|
|
||||||
state.cachedSettings = current
|
|
||||||
applyToEnv()
|
|
||||||
}
|
|
||||||
|
|
||||||
let writableCache: boolean | null = null
|
|
||||||
|
|
||||||
export function isSettingsWritable(): boolean {
|
|
||||||
if (writableCache !== null) return writableCache
|
|
||||||
try {
|
|
||||||
const filePath = getSettingsPath()
|
|
||||||
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
|
||||||
writableCache = false
|
|
||||||
return writableCache
|
|
||||||
}
|
|
||||||
const dir = path.dirname(filePath)
|
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
|
||||||
fs.accessSync(dir, fs.constants.W_OK)
|
|
||||||
writableCache = true
|
|
||||||
} catch {
|
|
||||||
writableCache = false
|
|
||||||
}
|
|
||||||
return writableCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only: reset module state
|
|
||||||
export function _resetForTests(): void {
|
|
||||||
state.cachedSettings = null
|
|
||||||
writableCache = null
|
|
||||||
for (const key of state.overlaidKeys) {
|
|
||||||
const original = state.originalEnv[key]
|
|
||||||
if (original === null) delete process.env[key]
|
|
||||||
else if (original !== undefined) process.env[key] = original
|
|
||||||
}
|
|
||||||
state.overlaidKeys = new Set()
|
|
||||||
state.originalEnv = {}
|
|
||||||
}
|
|
||||||
+612
-681
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
|||||||
export interface CachedResponse {
|
export interface CachedResponse {
|
||||||
promptText: string
|
promptText: string
|
||||||
hasImage: boolean
|
hasImage: boolean
|
||||||
// Name of the bundled example file the prompt is sent with
|
|
||||||
fileName?: string
|
|
||||||
xml: string
|
xml: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +254,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Replicate this in aws style",
|
promptText: "Replicate this in aws style",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
fileName: "architecture.png",
|
|
||||||
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
||||||
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
@@ -321,7 +318,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Replicate this flowchart.",
|
promptText: "Replicate this flowchart.",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
fileName: "example.png",
|
|
||||||
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||||
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
@@ -383,7 +379,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
{
|
{
|
||||||
promptText: "Summarize this paper as a diagram",
|
promptText: "Summarize this paper as a diagram",
|
||||||
hasImage: true,
|
hasImage: true,
|
||||||
fileName: "chain-of-thought.txt",
|
|
||||||
xml: `<mxCell id="title_bg" parent="1"
|
xml: `<mxCell id="title_bg" parent="1"
|
||||||
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
|
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
|
||||||
value="" vertex="1">
|
value="" vertex="1">
|
||||||
@@ -884,19 +879,14 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Examples that come with a file only match when that exact example file is
|
|
||||||
// attached, so a user's own file with the same prompt still goes to the model.
|
|
||||||
// Callers that can't tell file names (the server) only get text-only examples.
|
|
||||||
export function findCachedResponse(
|
export function findCachedResponse(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
hasImage: boolean,
|
hasImage: boolean,
|
||||||
fileName?: string,
|
|
||||||
): CachedResponse | undefined {
|
): CachedResponse | undefined {
|
||||||
return CACHED_EXAMPLE_RESPONSES.find(
|
return CACHED_EXAMPLE_RESPONSES.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.promptText === promptText &&
|
c.promptText === promptText &&
|
||||||
c.hasImage === hasImage &&
|
c.hasImage === hasImage &&
|
||||||
(!c.fileName || c.fileName === fileName) &&
|
|
||||||
c.xml !== "",
|
c.xml !== "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-91
@@ -6,37 +6,25 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
|||||||
export const MAX_FILES = 5
|
export const MAX_FILES = 5
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
// Helper function to validate file parts in messages
|
||||||
// Checks every message, since history is sent to the model too
|
|
||||||
export function validateFileParts(messages: any[]): {
|
export function validateFileParts(messages: any[]): {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
} {
|
} {
|
||||||
for (const message of messages) {
|
const lastMessage = messages[messages.length - 1]
|
||||||
const fileParts =
|
const fileParts =
|
||||||
message?.parts?.filter((p: any) => p.type === "file") || []
|
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
if (fileParts.length > MAX_FILES) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
for (const filePart of fileParts) {
|
||||||
// The client sends files inline. Any other URL would be downloaded
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
// by the server (AI SDK does that for models without URL support).
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
if (
|
if (filePart.url?.startsWith("data:")) {
|
||||||
typeof filePart.url !== "string" ||
|
|
||||||
!filePart.url.startsWith("data:")
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Files must be uploaded inline as data URLs.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
|
||||||
const base64Data = filePart.url.split(",")[1]
|
const base64Data = filePart.url.split(",")[1]
|
||||||
if (base64Data) {
|
if (base64Data) {
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
@@ -53,83 +41,49 @@ export function validateFileParts(messages: any[]): {
|
|||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tool-call input providers accept: a non-empty JSON object
|
// Helper function to check if diagram is minimal/empty
|
||||||
function isValidToolInput(input: unknown): boolean {
|
export function isMinimalDiagram(xml: string): boolean {
|
||||||
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
const stripped = xml.replace(/\s/g, "")
|
||||||
|
return !stripped.includes('id="2"')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
// Helper function to replace historical tool call XML with placeholders
|
||||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||||
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
|
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||||
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||||
return messages.map((msg) => {
|
return messages.map((msg) => {
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
const replacedContent = msg.content.map((part: any) => {
|
const replacedContent = msg.content
|
||||||
if (
|
.map((part: any) => {
|
||||||
part.type === "tool-call" &&
|
if (part.type === "tool-call") {
|
||||||
isValidToolInput(part.input) &&
|
const toolName = part.toolName
|
||||||
(part.toolName === "display_diagram" ||
|
// Fix invalid/undefined inputs from interrupted streaming
|
||||||
part.toolName === "edit_diagram")
|
if (
|
||||||
) {
|
!part.input ||
|
||||||
return {
|
typeof part.input !== "object" ||
|
||||||
...part,
|
Object.keys(part.input).length === 0
|
||||||
input: {
|
) {
|
||||||
placeholder:
|
// Skip tool calls with invalid inputs entirely
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
return null
|
||||||
},
|
}
|
||||||
|
if (
|
||||||
|
toolName === "display_diagram" ||
|
||||||
|
toolName === "edit_diagram"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
input: {
|
||||||
|
placeholder:
|
||||||
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return part
|
||||||
return part
|
})
|
||||||
})
|
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||||
return { ...msg, content: replacedContent }
|
return { ...msg, content: replacedContent }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
|
|
||||||
// together with their tool-results: providers reject a result whose call is missing.
|
|
||||||
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
|
||||||
export function dropInvalidToolCalls(messages: any[]): any[] {
|
|
||||||
const droppedIds = new Set<string>()
|
|
||||||
return messages
|
|
||||||
.map((msg) => {
|
|
||||||
if (!Array.isArray(msg.content)) return msg
|
|
||||||
const content = msg.content.filter((part: any) => {
|
|
||||||
if (
|
|
||||||
msg.role === "assistant" &&
|
|
||||||
part.type === "tool-call" &&
|
|
||||||
!isValidToolInput(part.input)
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`[chat-helpers] Dropping tool-call with invalid input:`,
|
|
||||||
{ toolName: part.toolName, input: part.input },
|
|
||||||
)
|
|
||||||
droppedIds.add(part.toolCallId)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Results always come after their call, so the id is known by now
|
|
||||||
return !(
|
|
||||||
part.type === "tool-result" &&
|
|
||||||
droppedIds.has(part.toolCallId)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return { ...msg, content }
|
|
||||||
})
|
|
||||||
.filter((msg) => !Array.isArray(msg.content) || msg.content.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix common LLM JSON mistakes in tool-call input before jsonrepair runs
|
|
||||||
export function fixToolInputJson(input: string): string {
|
|
||||||
return (
|
|
||||||
input
|
|
||||||
// Inconsistent quote escaping in XML attributes inside JSON strings:
|
|
||||||
// y="-20\" (opening quote unescaped, closing escaped) becomes y=\"-20\".
|
|
||||||
// Must run before the key fix below, which would rewrite the `="`.
|
|
||||||
.replace(/(\w+)="([^"]*?)\\"/g, '$1=\\"$2\\"')
|
|
||||||
// `:=` instead of `: `
|
|
||||||
.replace(/:=/g, ": ")
|
|
||||||
// `"key"= "` instead of `"key": "`, only for JSON keys
|
|
||||||
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { wrapLanguageModel } from "ai"
|
|
||||||
import { rejectionText } from "@/lib/output-token-limit"
|
|
||||||
|
|
||||||
type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claude 4.7 and later answer a non-default temperature, top_p or top_k,
|
|
||||||
* and the extended thinking budget (thinking type "enabled"), with a 400.
|
|
||||||
* TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these.
|
|
||||||
*/
|
|
||||||
const DEPRECATED_PARAM =
|
|
||||||
/`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i
|
|
||||||
|
|
||||||
interface CallParams {
|
|
||||||
temperature?: number
|
|
||||||
topP?: number
|
|
||||||
topK?: number
|
|
||||||
providerOptions?: Record<string, Record<string, unknown> | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
// What these models take instead of a budget. Without display "summarized"
|
|
||||||
// they think but send no thinking text to show.
|
|
||||||
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
|
|
||||||
|
|
||||||
/** Turn a thinking config of type "enabled" stored under key into adaptive */
|
|
||||||
function adaptiveThinking(
|
|
||||||
options: Record<string, unknown> | undefined,
|
|
||||||
key: string,
|
|
||||||
): Record<string, unknown> | undefined {
|
|
||||||
const config = options?.[key] as { type?: string } | undefined
|
|
||||||
if (config?.type !== "enabled") return options
|
|
||||||
return { ...options, [key]: ADAPTIVE_THINKING }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The params without the settings newer Claude models reject, or null when
|
|
||||||
* the error is about something else or there is nothing to change. The
|
|
||||||
* model then runs with its default sampling, and a thinking budget becomes
|
|
||||||
* adaptive thinking.
|
|
||||||
*/
|
|
||||||
export function withoutDeprecatedParams<T extends CallParams>(
|
|
||||||
error: unknown,
|
|
||||||
params: T,
|
|
||||||
): T | null {
|
|
||||||
const text = rejectionText(error)
|
|
||||||
if (!text || !DEPRECATED_PARAM.test(text)) return null
|
|
||||||
|
|
||||||
const { temperature, topP, topK, ...rest } = params
|
|
||||||
const options = params.providerOptions
|
|
||||||
const anthropic = adaptiveThinking(options?.anthropic, "thinking")
|
|
||||||
const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig")
|
|
||||||
const changed =
|
|
||||||
temperature !== undefined ||
|
|
||||||
topP !== undefined ||
|
|
||||||
topK !== undefined ||
|
|
||||||
anthropic !== options?.anthropic ||
|
|
||||||
bedrock !== options?.bedrock
|
|
||||||
if (!changed) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
...(options && {
|
|
||||||
providerOptions: {
|
|
||||||
...options,
|
|
||||||
...(anthropic && { anthropic }),
|
|
||||||
...(bedrock && { bedrock }),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
} as T
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry the stream once without the settings newer Claude models reject. */
|
|
||||||
export function withDeprecatedParamsFallback(
|
|
||||||
model: WrappedModel,
|
|
||||||
): WrappedModel {
|
|
||||||
return wrapLanguageModel({
|
|
||||||
model,
|
|
||||||
middleware: {
|
|
||||||
specificationVersion: "v3",
|
|
||||||
async wrapStream({ doStream, params, model: inner }) {
|
|
||||||
try {
|
|
||||||
return await doStream()
|
|
||||||
} catch (error) {
|
|
||||||
const retry = withoutDeprecatedParams(error, params)
|
|
||||||
if (!retry) throw error
|
|
||||||
console.warn(
|
|
||||||
"[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking",
|
|
||||||
)
|
|
||||||
return await inner.doStream(retry)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const DRAWIO_THEMES = [
|
|
||||||
"kennedy",
|
|
||||||
"atlas",
|
|
||||||
"dark",
|
|
||||||
"min",
|
|
||||||
"sketch",
|
|
||||||
"simple",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
|
|
||||||
|
|
||||||
export function isDrawioTheme(value: unknown): value is DrawioTheme {
|
|
||||||
return (
|
|
||||||
typeof value === "string" &&
|
|
||||||
(DRAWIO_THEMES as readonly string[]).includes(value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -64,13 +64,10 @@ interface QuotaCheckResult {
|
|||||||
* Check all quotas and increment request count atomically.
|
* Check all quotas and increment request count atomically.
|
||||||
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||||
* Each day automatically gets a new item - no explicit reset needed.
|
* Each day automatically gets a new item - no explicit reset needed.
|
||||||
* A request limit of 0 means none; increment 0 checks the limits without
|
|
||||||
* counting a request (the screenshot check).
|
|
||||||
*/
|
*/
|
||||||
export async function checkAndIncrementRequest(
|
export async function checkAndIncrementRequest(
|
||||||
ip: string,
|
ip: string,
|
||||||
limits: QuotaLimits,
|
limits: QuotaLimits,
|
||||||
increment = 1,
|
|
||||||
): Promise<QuotaCheckResult> {
|
): Promise<QuotaCheckResult> {
|
||||||
// Skip if quota tracking not enabled
|
// Skip if quota tracking not enabled
|
||||||
if (!client || !TABLE) {
|
if (!client || !TABLE) {
|
||||||
@@ -102,7 +99,7 @@ export async function checkAndIncrementRequest(
|
|||||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||||
`,
|
`,
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: {
|
||||||
":one": { N: String(increment) },
|
":one": { N: "1" },
|
||||||
":minute": { S: currentMinute },
|
":minute": { S: currentMinute },
|
||||||
":reqLimit": { N: String(limits.requests || 999999) },
|
":reqLimit": { N: String(limits.requests || 999999) },
|
||||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
export const i18n = {
|
export const i18n = {
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
locales: ["en", "zh", "ja", "zh-Hant"],
|
locales: ["en", "zh", "ja"],
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Locale = (typeof i18n)["locales"][number]
|
export type Locale = (typeof i18n)["locales"][number]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user