mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
4 Commits
v0.4.11
...
feature/ur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1198cc2a9b | ||
|
|
a61e89d6d0 | ||
|
|
580d42f535 | ||
|
|
64268b0fac |
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"],
|
"extends": ["config:recommended"],
|
||||||
"schedule": ["after 10am on the first day of the month"],
|
"schedule": ["after 10am on saturday"],
|
||||||
"timezone": "Asia/Tokyo",
|
"timezone": "Asia/Tokyo",
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"matchPackagePatterns": ["*"],
|
"matchPackagePatterns": ["*"],
|
||||||
"groupName": "major dependencies",
|
|
||||||
"automerge": false
|
"automerge": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
60
.github/workflows/electron-release.yml
vendored
60
.github/workflows/electron-release.yml
vendored
@@ -11,8 +11,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Mac and Linux: Build and publish directly (no signing needed)
|
build:
|
||||||
build-mac-linux:
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -21,9 +20,13 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
platform: mac
|
platform: mac
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux
|
platform: linux
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -37,58 +40,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Build and publish
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Windows: Build, sign with SignPath, then publish
|
|
||||||
build-windows:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
# Build WITHOUT publishing
|
|
||||||
- name: Build Windows app
|
|
||||||
run: npm run dist:win:build
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Upload unsigned artifacts for signing
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
id: upload-unsigned
|
|
||||||
with:
|
|
||||||
name: windows-unsigned
|
|
||||||
path: release/*.exe
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Sign with SignPath
|
|
||||||
uses: signpath/github-action-submit-signing-request@v2
|
|
||||||
with:
|
|
||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
||||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
|
||||||
project-slug: 'next-ai-draw-io'
|
|
||||||
signing-policy-slug: 'test-signing'
|
|
||||||
artifact-configuration-slug: 'windows-exe'
|
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
|
||||||
wait-for-completion: true
|
|
||||||
output-artifact-directory: release-signed
|
|
||||||
|
|
||||||
- name: Upload signed artifacts to release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: release-signed/*.exe
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
name: Lint & Unit Tests
|
name: Lint & Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -68,5 +68,3 @@ CLAUDE.md
|
|||||||
|
|
||||||
# edgeone
|
# edgeone
|
||||||
.edgeone
|
.edgeone
|
||||||
opencode.json
|
|
||||||
ai-models.json
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -40,12 +40,11 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
|
||||||
- [Star History](#star-history)
|
- [Star History](#star-history)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -186,7 +185,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
|
|||||||
|
|
||||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||||
|
|
||||||
### Deploy on Vercel
|
### Deploy on Vercel (Recommended)
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -207,13 +206,11 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -222,10 +219,6 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||||
@@ -252,10 +245,6 @@ For support or inquiries, please open an issue on the GitHub repository or conta
|
|||||||
|
|
||||||
- Email: me[at]jiang.jp
|
- Email: me[at]jiang.jp
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
|
|||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutCN() {
|
export default function AboutCN() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -97,6 +108,42 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
当前使用限制:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
请求/天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Token/天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Token/分钟
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* 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">
|
||||||
@@ -292,13 +339,11 @@ export default function AboutCN() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutJA() {
|
export default function AboutJA() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -105,6 +116,42 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
現在の使用制限:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
リクエスト/日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
トークン/日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
トークン/分
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -307,13 +354,11 @@ export default function AboutJA() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注:<code>claude-sonnet-4-5</code>
|
注:<code>claude-sonnet-4-5</code>
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -107,6 +118,42 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Please note the current usage limits:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
requests/day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
tokens/day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
tokens/min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -326,13 +373,11 @@ export default function About() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
|||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
|
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -28,6 +29,7 @@ export default function Home() {
|
|||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
@@ -64,6 +66,13 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedCloseProtection = localStorage.getItem(
|
||||||
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
|
)
|
||||||
|
if (savedCloseProtection === "true") {
|
||||||
|
setCloseProtection(true)
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -137,6 +146,20 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Show confirmation dialog when user tries to leave the page
|
||||||
|
useEffect(() => {
|
||||||
|
if (!closeProtection) return
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
}, [closeProtection])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -220,6 +243,7 @@ export default function Home() {
|
|||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
onCloseProtectionChange={setCloseProtection}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { findServerModelById } from "@/lib/server-model-config"
|
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
@@ -169,7 +168,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Read client AI provider overrides from headers
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
const provider = req.headers.get("x-ai-provider")
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
let baseUrl = req.headers.get("x-ai-base-url")
|
||||||
const selectedModelId = req.headers.get("x-selected-model-id")
|
|
||||||
|
|
||||||
// For EdgeOne provider, construct full URL from request origin
|
// For EdgeOne provider, construct full URL from request origin
|
||||||
// because createOpenAI needs absolute URL, not relative path
|
// because createOpenAI needs absolute URL, not relative path
|
||||||
@@ -181,30 +179,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||||
const cookieHeader = req.headers.get("cookie")
|
const cookieHeader = req.headers.get("cookie")
|
||||||
|
|
||||||
// Check if this is a server model with custom env var names
|
|
||||||
let serverModelConfig: {
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
provider?: string
|
|
||||||
} = {}
|
|
||||||
if (selectedModelId?.startsWith("server:")) {
|
|
||||||
const serverModel = await findServerModelById(selectedModelId)
|
|
||||||
console.log(
|
|
||||||
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
|
||||||
)
|
|
||||||
if (serverModel) {
|
|
||||||
serverModelConfig = {
|
|
||||||
apiKeyEnv: serverModel.apiKeyEnv,
|
|
||||||
baseUrlEnv: serverModel.baseUrlEnv,
|
|
||||||
// Use actual provider from config (client header may have incorrect value due to ID format change)
|
|
||||||
provider: serverModel.provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientOverrides = {
|
const clientOverrides = {
|
||||||
// Server model provider takes precedence over client header
|
provider,
|
||||||
provider: serverModelConfig.provider || provider,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
@@ -213,10 +189,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
// Server model custom env var names
|
|
||||||
...serverModelConfig,
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
|
||||||
// Pass cookies for EdgeOne Pages authentication
|
// Pass cookies for EdgeOne Pages authentication
|
||||||
...(provider === "edgeone" &&
|
...(provider === "edgeone" &&
|
||||||
cookieHeader && {
|
cookieHeader && {
|
||||||
@@ -227,10 +199,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get AI model with optional client overrides
|
// Get AI model with optional client overrides
|
||||||
const { model, providerOptions, headers, modelId } =
|
const { model, providerOptions, headers, modelId } =
|
||||||
getAIModel(clientOverrides)
|
getAIModel(clientOverrides)
|
||||||
@@ -473,13 +441,6 @@ ${userInputText}
|
|||||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||||
// Fix `= "` instead of `: "`
|
// Fix `= "` instead of `: "`
|
||||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||||
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
|
||||||
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
|
||||||
// Example: y="-20\" should be y=\"-20\"
|
|
||||||
inputToRepair = inputToRepair.replace(
|
|
||||||
/(\w+)="([^"]*?)\\"/g,
|
|
||||||
'$1=\\"$2\\"',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Use jsonrepair to fix truncated JSON
|
// Use jsonrepair to fix truncated JSON
|
||||||
const repairedInput = jsonrepair(inputToRepair)
|
const repairedInput = jsonrepair(inputToRepair)
|
||||||
|
|||||||
@@ -1,11 +1,61 @@
|
|||||||
import { extract } 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 { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
// SSRF protection - block private/internal addresses
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
if (a === 10) return true // 10.0.0.0/8
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||||
|
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||||
|
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return true // Invalid URL - block it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { url } = await req.json()
|
const { url } = await req.json()
|
||||||
@@ -28,7 +78,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection
|
// SSRF protection
|
||||||
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
if (isPrivateUrl(url)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Cannot access private/internal URLs" },
|
{ error: "Cannot access private/internal URLs" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
|
||||||
|
|
||||||
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
|
||||||
// This ensures Docker users can set these values when starting containers
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
return NextResponse.json({
|
|
||||||
models,
|
|
||||||
hasConfig: models.length > 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,16 +3,74 @@ import { createAnthropic } from "@ai-sdk/anthropic"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
import { createGateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
|
*/
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if (a === 10) return true
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if (a === 192 && b === 168) return true
|
||||||
|
// 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 169 && b === 254) return true
|
||||||
|
// 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 127) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Invalid URL - block it
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -22,8 +80,6 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
// Vertex AI specific
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -37,8 +93,6 @@ export async function POST(req: Request) {
|
|||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
// Note: Express Mode only needs vertexApiKey
|
|
||||||
vertexApiKey,
|
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -49,7 +103,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -67,16 +121,6 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (provider === "vertexai") {
|
|
||||||
if (!vertexApiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "Vertex AI API key is required for Express Mode",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "API key is required" },
|
{ valid: false, error: "API key is required" },
|
||||||
@@ -114,15 +158,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vertexai": {
|
|
||||||
const vertex = createVertex({
|
|
||||||
apiKey: vertexApiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = vertex(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const azure = createOpenAI({
|
const azure = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -167,7 +202,7 @@ export async function POST(req: Request) {
|
|||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const sf = createOpenAI({
|
const sf = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
||||||
})
|
})
|
||||||
model = sf.chat(modelId)
|
model = sf.chat(modelId)
|
||||||
break
|
break
|
||||||
@@ -239,75 +274,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
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:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
|||||||
@@ -244,19 +244,6 @@
|
|||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: oklch(0.75 0.01 260);
|
background-color: oklch(0.75 0.01 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar */
|
|
||||||
.dark .scrollbar-thin {
|
|
||||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background-color: oklch(0.35 0.015 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: oklch(0.45 0.015 260);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth page transitions */
|
/* Smooth page transitions */
|
||||||
|
|||||||
@@ -169,27 +169,3 @@ export const ModelSelectorName = ({
|
|||||||
}: ModelSelectorNameProps) => (
|
}: ModelSelectorNameProps) => (
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||||
)
|
)
|
||||||
|
|
||||||
export type ModelSelectorSectionHeaderProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorSectionHeader = ({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: ModelSelectorSectionHeaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||||
import { isRealDiagram } from "@/lib/utils"
|
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -160,9 +158,6 @@ interface ChatInputProps {
|
|||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
// Focus control props
|
|
||||||
shouldFocus?: boolean
|
|
||||||
onFocused?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -182,12 +177,9 @@ export function ChatInput({
|
|||||||
onModelSelect = () => {},
|
onModelSelect = () => {},
|
||||||
showUnvalidatedModels = false,
|
showUnvalidatedModels = false,
|
||||||
onConfigureModels = () => {},
|
onConfigureModels = () => {},
|
||||||
shouldFocus = false,
|
|
||||||
onFocused,
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
chartXML,
|
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
showSaveDialog,
|
showSaveDialog,
|
||||||
@@ -197,23 +189,9 @@ export function ChatInput({
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
// Focus the textarea when shouldFocus becomes true
|
|
||||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldFocus) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
textareaRef.current?.focus()
|
|
||||||
onFocused?.()
|
|
||||||
}, 150)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [shouldFocus, onFocused])
|
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
// 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
|
||||||
@@ -230,36 +208,13 @@ export function ChatInput({
|
|||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}, [input, adjustTextareaHeight])
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
// Load send shortcut preference from localStorage and listen for changes
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
|
||||||
if (stored) setSendShortcut(stored)
|
|
||||||
|
|
||||||
const handleChange = (e: CustomEvent<string>) =>
|
|
||||||
setSendShortcut(e.detail)
|
|
||||||
window.addEventListener(
|
|
||||||
"sendShortcutChange",
|
|
||||||
handleChange as EventListener,
|
|
||||||
)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener(
|
|
||||||
"sendShortcutChange",
|
|
||||||
handleChange as EventListener,
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
onChange(e)
|
onChange(e)
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const shouldSend =
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
sendShortcut === "enter"
|
|
||||||
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
|
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
|
||||||
|
|
||||||
if (shouldSend) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.currentTarget.closest("form")
|
const form = e.currentTarget.closest("form")
|
||||||
if (form && input.trim() && !isDisabled) {
|
if (form && input.trim() && !isDisabled) {
|
||||||
@@ -392,12 +347,6 @@ export function ChatInput({
|
|||||||
|
|
||||||
setShowUrlDialog(false)
|
setShowUrlDialog(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Remove the URL from the data map on error
|
|
||||||
const newUrlData = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
newUrlData.delete(url)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{error instanceof Error
|
{error instanceof Error
|
||||||
@@ -452,7 +401,7 @@ export function ChatInput({
|
|||||||
placeholder={dict.chat.placeholder}
|
placeholder={dict.chat.placeholder}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
aria-label="Chat input"
|
aria-label="Chat input"
|
||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||||
@@ -474,7 +423,7 @@ export function ChatInput({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
disabled={isDisabled || !isRealDiagram(chartXML)}
|
disabled={isDisabled}
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1114,7 +1114,7 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
|
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
|
||||||
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
||||||
{
|
{
|
||||||
section.content
|
section.content
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ interface ChatPanelProps {
|
|||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
|
onCloseProtectionChange?: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants for tool states
|
// Constants for tool states
|
||||||
@@ -110,6 +111,7 @@ export default function ChatPanel({
|
|||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
|
onCloseProtectionChange,
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
@@ -173,7 +175,6 @@ export default function ChatPanel({
|
|||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
const [tpmLimit, setTpmLimit] = useState(0)
|
||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
|
||||||
|
|
||||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -857,7 +858,6 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
|
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setInput("")
|
|
||||||
clearDiagram()
|
clearDiagram()
|
||||||
setDiagramHistory([])
|
setDiagramHistory([])
|
||||||
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
||||||
@@ -872,9 +872,6 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Clear URL param to show blank state
|
// Clear URL param to show blank state
|
||||||
router.replace(window.location.pathname, { scroll: false })
|
router.replace(window.location.pathname, { scroll: false })
|
||||||
|
|
||||||
// After starting a fresh chat, move focus back to the chat input
|
|
||||||
setShouldFocusInput(true)
|
|
||||||
}, [
|
}, [
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
@@ -968,14 +965,6 @@ export default function ChatPanel({
|
|||||||
...(config.awsSessionToken && {
|
...(config.awsSessionToken && {
|
||||||
"x-aws-session-token": config.awsSessionToken,
|
"x-aws-session-token": config.awsSessionToken,
|
||||||
}),
|
}),
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
...(config.vertexApiKey && {
|
|
||||||
"x-vertex-api-key": config.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
// Send selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
|
|
||||||
...(config.selectedModelId && {
|
|
||||||
"x-selected-model-id": config.selectedModelId,
|
|
||||||
}),
|
}),
|
||||||
...(minimalStyle && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
@@ -1301,14 +1290,13 @@ export default function ChatPanel({
|
|||||||
onModelSelect={modelConfig.setSelectedModelId}
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||||
shouldFocus={shouldFocusInput}
|
|
||||||
onFocused={() => setShouldFocusInput(false)}
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
open={showSettingsDialog}
|
open={showSettingsDialog}
|
||||||
onOpenChange={setShowSettingsDialog}
|
onOpenChange={setShowSettingsDialog}
|
||||||
|
onCloseProtectionChange={onCloseProtectionChange}
|
||||||
drawioUi={drawioUi}
|
drawioUi={drawioUi}
|
||||||
onToggleDrawioUi={onToggleDrawioUi}
|
onToggleDrawioUi={onToggleDrawioUi}
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function HistoryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -78,9 +78,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
sglang: "openai", // SGLang is OpenAI-compatible
|
sglang: "openai", // SGLang is OpenAI-compatible
|
||||||
gateway: "vercel",
|
gateway: "vercel",
|
||||||
edgeone: "tencent-cloud",
|
edgeone: "tencent-cloud",
|
||||||
vertexai: "google",
|
|
||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider logo component
|
// Provider logo component
|
||||||
@@ -238,7 +236,6 @@ export function ModelConfigDialog({
|
|||||||
"awsAccessKeyId",
|
"awsAccessKeyId",
|
||||||
"awsSecretAccessKey",
|
"awsSecretAccessKey",
|
||||||
"awsRegion",
|
"awsRegion",
|
||||||
"vertexApiKey",
|
|
||||||
]
|
]
|
||||||
if (credentialFields.includes(field)) {
|
if (credentialFields.includes(field)) {
|
||||||
setValidationStatus("idle")
|
setValidationStatus("idle")
|
||||||
@@ -282,7 +279,6 @@ export function ModelConfigDialog({
|
|||||||
// Check credentials based on provider type
|
// Check credentials based on provider type
|
||||||
const isBedrock = selectedProvider.provider === "bedrock"
|
const isBedrock = selectedProvider.provider === "bedrock"
|
||||||
const isEdgeOne = selectedProvider.provider === "edgeone"
|
const isEdgeOne = selectedProvider.provider === "edgeone"
|
||||||
const isVertexAI = selectedProvider.provider === "vertexai"
|
|
||||||
if (isBedrock) {
|
if (isBedrock) {
|
||||||
if (
|
if (
|
||||||
!selectedProvider.awsAccessKeyId ||
|
!selectedProvider.awsAccessKeyId ||
|
||||||
@@ -291,11 +287,6 @@ export function ModelConfigDialog({
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (isVertexAI) {
|
|
||||||
// Vertex AI requires vertexApiKey for Express Mode
|
|
||||||
if (!selectedProvider.vertexApiKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if (!isEdgeOne && !selectedProvider.apiKey) {
|
} else if (!isEdgeOne && !selectedProvider.apiKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -336,8 +327,6 @@ export function ModelConfigDialog({
|
|||||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||||
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
|
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
|
||||||
awsRegion: selectedProvider.awsRegion,
|
awsRegion: selectedProvider.awsRegion,
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: selectedProvider.vertexApiKey,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -877,153 +866,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : selectedProvider.provider ===
|
) : selectedProvider.provider ===
|
||||||
"vertexai" ? (
|
"edgeone" ? (
|
||||||
<>
|
|
||||||
{/* Vertex AI API Key */}
|
|
||||||
<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" />
|
|
||||||
API Key
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
id="vertex-api-key"
|
|
||||||
type={
|
|
||||||
showApiKey
|
|
||||||
? "text"
|
|
||||||
: "password"
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
selectedProvider.vertexApiKey ||
|
|
||||||
""
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
e,
|
|
||||||
) =>
|
|
||||||
handleProviderUpdate(
|
|
||||||
"vertexApiKey",
|
|
||||||
e
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Enter your Vertex AI API key"
|
|
||||||
className="h-9 pr-10 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setShowApiKey(
|
|
||||||
!showApiKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
showApiKey
|
|
||||||
? "Hide API key"
|
|
||||||
: "Show API key"
|
|
||||||
}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
|
||||||
>
|
|
||||||
{showApiKey ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant={
|
|
||||||
validationStatus ===
|
|
||||||
"success"
|
|
||||||
? "outline"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
onClick={
|
|
||||||
handleValidate
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
!selectedProvider.vertexApiKey ||
|
|
||||||
validationStatus ===
|
|
||||||
"validating"
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"h-9 px-4",
|
|
||||||
validationStatus ===
|
|
||||||
"success" &&
|
|
||||||
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validationStatus ===
|
|
||||||
"validating" ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : validationStatus ===
|
|
||||||
"success" ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
|
||||||
{
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.verified
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.test
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{validationStatus ===
|
|
||||||
"error" &&
|
|
||||||
validationError && (
|
|
||||||
<p className="text-xs text-destructive flex items-center gap-1">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
{
|
|
||||||
validationError
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</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" />
|
|
||||||
Base URL{" "}
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
(optional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="vertex-base-url"
|
|
||||||
value={
|
|
||||||
selectedProvider.baseUrl ||
|
|
||||||
""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleProviderUpdate(
|
|
||||||
"baseUrl",
|
|
||||||
e.target
|
|
||||||
.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Custom endpoint URL"
|
|
||||||
className="h-9 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : selectedProvider.provider ===
|
|
||||||
"ollama" ||
|
|
||||||
selectedProvider.provider ===
|
|
||||||
"edgeone" ? (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Monitor,
|
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
User,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
ModelSelectorLogo,
|
ModelSelectorLogo,
|
||||||
ModelSelectorName,
|
ModelSelectorName,
|
||||||
ModelSelector as ModelSelectorRoot,
|
ModelSelector as ModelSelectorRoot,
|
||||||
ModelSelectorSectionHeader,
|
|
||||||
ModelSelectorSeparator,
|
ModelSelectorSeparator,
|
||||||
ModelSelectorTrigger,
|
ModelSelectorTrigger,
|
||||||
} from "@/components/ai-elements/model-selector"
|
} from "@/components/ai-elements/model-selector"
|
||||||
@@ -52,9 +49,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
||||||
gateway: "vercel",
|
gateway: "vercel",
|
||||||
edgeone: "tencent-cloud",
|
edgeone: "tencent-cloud",
|
||||||
vertexai: "google",
|
|
||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
@@ -66,11 +61,7 @@ function groupModelsByProvider(
|
|||||||
{ provider: string; models: FlattenedModel[] }
|
{ provider: string; models: FlattenedModel[] }
|
||||||
>()
|
>()
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// For server models, strip "Server · " prefix for cleaner grouping
|
const key = model.providerLabel
|
||||||
const key =
|
|
||||||
model.source === "server"
|
|
||||||
? model.providerLabel.replace(/^Server · /, "")
|
|
||||||
: model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
const existing = groups.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
@@ -98,26 +89,10 @@ export function ModelSelector({
|
|||||||
}
|
}
|
||||||
return models.filter((m) => m.validated === true)
|
return models.filter((m) => m.validated === true)
|
||||||
}, [models, showUnvalidatedModels])
|
}, [models, showUnvalidatedModels])
|
||||||
|
const groupedModels = useMemo(
|
||||||
// Separate server and user models
|
() => groupModelsByProvider(displayModels),
|
||||||
const serverModels = useMemo(
|
|
||||||
() => displayModels.filter((m) => m.source === "server"),
|
|
||||||
[displayModels],
|
[displayModels],
|
||||||
)
|
)
|
||||||
const userModels = useMemo(
|
|
||||||
() => displayModels.filter((m) => m.source !== "server"),
|
|
||||||
[displayModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Group each category separately
|
|
||||||
const groupedServerModels = useMemo(
|
|
||||||
() => groupModelsByProvider(serverModels),
|
|
||||||
[serverModels],
|
|
||||||
)
|
|
||||||
const groupedUserModels = useMemo(
|
|
||||||
() => groupModelsByProvider(userModels),
|
|
||||||
[userModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Find selected model for display
|
// Find selected model for display
|
||||||
const selectedModel = useMemo(
|
const selectedModel = useMemo(
|
||||||
@@ -184,7 +159,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
"hover:bg-accent gap-1.5 h-8 px-2 transition-all 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
|
||||||
@@ -221,169 +196,83 @@ export function ModelSelector({
|
|||||||
: dict.modelConfig.noModelsFound}
|
: dict.modelConfig.noModelsFound}
|
||||||
</ModelSelectorEmpty>
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
{/* Server Default Option */}
|
||||||
{serverModels.length === 0 && (
|
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||||
<ModelSelectorGroup
|
<ModelSelectorItem
|
||||||
heading={dict.modelConfig.default}
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
!selectedModelId && "bg-accent",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ModelSelectorItem
|
<Check
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer",
|
"mr-2 h-4 w-4",
|
||||||
!selectedModelId && "bg-accent",
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.serverDefault}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
|
||||||
|
{/* Configured Models by Provider */}
|
||||||
|
{Array.from(groupedModels.entries()).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{ provider, models: providerModels },
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={providerLabel}
|
||||||
|
heading={providerLabel}
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
{providerModels.map((model) => (
|
<Check
|
||||||
<ModelSelectorItem
|
className={cn(
|
||||||
key={model.id}
|
"mr-2 h-4 w-4",
|
||||||
value={model.modelId}
|
selectedModelId === model.id
|
||||||
onSelect={() =>
|
? "opacity-100"
|
||||||
handleSelect(model.id)
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] || provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{model.modelId}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !== true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
}
|
}
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<Check
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
className={cn(
|
</span>
|
||||||
"mr-2 h-4 w-4",
|
)}
|
||||||
selectedModelId ===
|
</ModelSelectorItem>
|
||||||
model.id
|
))}
|
||||||
? "opacity-100"
|
</ModelSelectorGroup>
|
||||||
: "opacity-0",
|
),
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{model.modelId}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.isDefault && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.serverDefaultModel
|
|
||||||
}
|
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict.modelConfig
|
|
||||||
.default
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
))}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Models Section */}
|
|
||||||
{userModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<ModelSelectorSeparator />
|
|
||||||
)}
|
|
||||||
<ModelSelectorSectionHeader
|
|
||||||
icon={<User />}
|
|
||||||
label={dict.modelConfig.userModels}
|
|
||||||
/>
|
|
||||||
{Array.from(groupedUserModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`user-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map((model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.modelId}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(model.id)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{model.modelId}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
))}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Configure Option */}
|
{/* Configure Option */}
|
||||||
@@ -392,7 +281,7 @@ export function ModelSelector({
|
|||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
value="__configure__"
|
value="__configure__"
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { 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, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,7 +24,6 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
|
|
||||||
// Reusable setting item component for consistent layout
|
// Reusable setting item component for consistent layout
|
||||||
function SettingItem({
|
function SettingItem({
|
||||||
@@ -61,6 +59,7 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCloseProtectionChange?: (enabled: boolean) => void
|
||||||
drawioUi: "min" | "sketch"
|
drawioUi: "min" | "sketch"
|
||||||
onToggleDrawioUi: () => void
|
onToggleDrawioUi: () => void
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
@@ -70,6 +69,7 @@ interface SettingsDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
|
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
@@ -82,6 +82,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onCloseProtectionChange,
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
@@ -94,18 +95,13 @@ function SettingsContent({
|
|||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const search = useSearchParams()
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
|
const [closeProtection, setCloseProtection] = useState(true)
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
|
|
||||||
// Proxy settings state (Electron only)
|
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
|
||||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
@@ -147,20 +143,13 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedSendShortcut = localStorage.getItem(
|
const storedCloseProtection = localStorage.getItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
// Default to true if not set
|
||||||
|
setCloseProtection(storedCloseProtection !== "false")
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
|
||||||
if (window.electronAPI?.getProxy) {
|
|
||||||
window.electronAPI.getProxy().then((config) => {
|
|
||||||
setHttpProxy(config.httpProxy || "")
|
|
||||||
setHttpsProxy(config.httpsProxy || "")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -168,13 +157,6 @@ function SettingsContent({
|
|||||||
// Save locale to localStorage for persistence across restarts
|
// Save locale to localStorage for persistence across restarts
|
||||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||||
|
|
||||||
// Notify Electron main process to update its menu language
|
|
||||||
if (window.electronAPI?.setUserLocale) {
|
|
||||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
|
||||||
console.error("Failed to sync locale with Electron:", error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = pathname.split("/")
|
const parts = pathname.split("/")
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
parts[1] = lang
|
parts[1] = lang
|
||||||
@@ -226,46 +208,6 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyProxy = async () => {
|
|
||||||
if (!window.electronAPI?.setProxy) return
|
|
||||||
|
|
||||||
// Validate proxy URLs (must start with http:// or https://)
|
|
||||||
const validateProxyUrl = (url: string): boolean => {
|
|
||||||
if (!url) return true // Empty is OK
|
|
||||||
return url.startsWith("http://") || url.startsWith("https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedHttp = httpProxy.trim()
|
|
||||||
const trimmedHttps = httpsProxy.trim()
|
|
||||||
|
|
||||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
|
||||||
toast.error("HTTP Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
|
||||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsApplyingProxy(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.setProxy({
|
|
||||||
httpProxy: trimmedHttp || undefined,
|
|
||||||
httpsProxy: trimmedHttps || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(dict.settings.proxyApplied)
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to apply proxy settings")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to apply proxy settings")
|
|
||||||
} finally {
|
|
||||||
setIsApplyingProxy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -391,6 +333,25 @@ function SettingsContent({
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Close Protection */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.closeProtection}
|
||||||
|
description={dict.settings.closeProtectionDescription}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="close-protection"
|
||||||
|
checked={closeProtection}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setCloseProtection(checked)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
|
checked.toString(),
|
||||||
|
)
|
||||||
|
onCloseProtectionChange?.(checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramStyle}
|
label={dict.settings.diagramStyle}
|
||||||
@@ -409,91 +370,6 @@ function SettingsContent({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.sendShortcut}
|
|
||||||
description={dict.settings.sendShortcutDescription}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={sendShortcut}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSendShortcut(value)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.sendShortcut,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("sendShortcutChange", {
|
|
||||||
detail: value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="send-shortcut-select"
|
|
||||||
className="w-[170px] h-9 rounded-xl"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="enter">
|
|
||||||
{dict.settings.enterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ctrl-enter">
|
|
||||||
{dict.settings.ctrlEnterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Proxy Settings - Electron only */}
|
|
||||||
{typeof window !== "undefined" &&
|
|
||||||
window.electronAPI?.isElectron && (
|
|
||||||
<div className="py-4 space-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
{dict.settings.proxy}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.proxyDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
id="http-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="https-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpsProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpsProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleApplyProxy}
|
|
||||||
disabled={isApplyingProxy}
|
|
||||||
className="h-9 px-4 rounded-xl w-full"
|
|
||||||
>
|
|
||||||
{isApplyingProxy
|
|
||||||
? "..."
|
|
||||||
: dict.settings.applyProxy}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -35,14 +35,14 @@ export function UrlInputDialog({
|
|||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
setError(dict.url.enterUrl)
|
setError("Please enter a URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(url)
|
new URL(url)
|
||||||
} catch {
|
} catch {
|
||||||
setError(dict.url.invalidFormat)
|
setError("Invalid URL format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# 常见问题解答 (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 无法导出 PDF
|
|
||||||
|
|
||||||
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
|
|
||||||
|
|
||||||
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
|
|
||||||
|
|
||||||
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
|
|
||||||
|
|
||||||
**相关 Issue**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
|
|
||||||
|
|
||||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
|
||||||
|
|
||||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
|
||||||
|
|
||||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
|
|
||||||
|
|
||||||
**相关 Issue**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 自建模型只思考不画图
|
|
||||||
|
|
||||||
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
|
|
||||||
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
|
|
||||||
|
|
||||||
**解决方案**: 开启 tool calling,例如 vLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关 Issue**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 上传图片后提示"未提供图片"
|
|
||||||
|
|
||||||
**问题**: 上传图片后,系统显示"未提供图片"错误
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
|
||||||
- 模型名带 `vision` 或 `vl` 的支持图片
|
|
||||||
- 更新到最新版本(v0.4.9+)
|
|
||||||
|
|
||||||
**相关 Issue**: #324, #421, #469
|
|
||||||
@@ -37,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [安装](#安装)
|
- [安装](#安装)
|
||||||
- [部署](#部署)
|
- [部署](#部署)
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||||
- [部署到Vercel](#部署到vercel)
|
- [部署到Vercel(推荐)](#部署到vercel推荐)
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||||
- [多提供商支持](#多提供商支持)
|
- [多提供商支持](#多提供商支持)
|
||||||
- [工作原理](#工作原理)
|
- [工作原理](#工作原理)
|
||||||
- [支持与联系](#支持与联系)
|
- [支持与联系](#支持与联系)
|
||||||
- [常见问题](#常见问题)
|
|
||||||
- [Star历史](#star历史)
|
- [Star历史](#star历史)
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
@@ -180,7 +179,7 @@ npm run dev
|
|||||||
|
|
||||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
||||||
|
|
||||||
### 部署到Vercel
|
### 部署到Vercel(推荐)
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -200,13 +199,11 @@ npm run dev
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -214,10 +211,6 @@ npm run dev
|
|||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||||
|
|
||||||
### 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
@@ -244,10 +237,6 @@ npm run dev
|
|||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
- 邮箱:me[at]jiang.jp
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
|
||||||
|
|
||||||
## Star历史
|
## Star历史
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|||||||
@@ -152,19 +152,6 @@ AI_PROVIDER=ollama
|
|||||||
AI_MODEL=llama3.2
|
AI_MODEL=llama3.2
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义 URL:
|
可选的自定义 URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -217,63 +204,6 @@ AI_MODEL=openai/gpt-4o
|
|||||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
|
|
||||||
|
|
||||||
### 配置方式
|
|
||||||
|
|
||||||
**方式一:环境变量**(推荐用于云部署)
|
|
||||||
|
|
||||||
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:配置文件**
|
|
||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字段说明
|
|
||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
|
|
||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
|
||||||
|
|
||||||
### 说明
|
|
||||||
|
|
||||||
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
|
|
||||||
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"` 但 `apiKeyEnv` 不同)。
|
|
||||||
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
|
|
||||||
|
|
||||||
## 模型能力要求
|
## 模型能力要求
|
||||||
|
|
||||||
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# Frequently Asked Questions (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Cannot Export PDF
|
|
||||||
|
|
||||||
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
|
|
||||||
|
|
||||||
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
|
|
||||||
|
|
||||||
**Solution**: Export as image (PNG) first, then print to PDF
|
|
||||||
|
|
||||||
**Related Issues**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
|
|
||||||
|
|
||||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
|
||||||
|
|
||||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
|
||||||
|
|
||||||
**Solution**: Must pass via `args` at build time:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
|
|
||||||
|
|
||||||
**Related Issues**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Self-hosted Model Only Thinks But Doesn't Draw
|
|
||||||
|
|
||||||
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
|
|
||||||
2. **Tool calling not enabled** - Model service needs tool use configuration
|
|
||||||
|
|
||||||
**Solution**: Enable tool calling, e.g., vLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**Related Issues**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. "No Image Provided" After Uploading Image
|
|
||||||
|
|
||||||
**Problem**: After uploading an image, the system shows "No image provided" error
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
|
|
||||||
- Models with `vision` or `vl` in name support images
|
|
||||||
- Update to latest version (v0.4.9+)
|
|
||||||
|
|
||||||
**Related Issues**: #324, #421, #469
|
|
||||||
@@ -33,21 +33,6 @@ Optional custom endpoint:
|
|||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
GOOGLE_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Google Vertex AI (Enterprise GCP)
|
|
||||||
|
|
||||||
Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
### OpenAI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -173,19 +158,6 @@ Optional custom URL:
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
|
|
||||||
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
||||||
@@ -229,66 +201,9 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
|
|
||||||
|
|
||||||
### Configuration Methods
|
|
||||||
|
|
||||||
**Option 1: Environment Variable** (recommended for cloud deployments)
|
|
||||||
|
|
||||||
Set `AI_MODELS_CONFIG` as a JSON string:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Config File**
|
|
||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
|
||||||
|
|
||||||
### Example Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Reference
|
|
||||||
|
|
||||||
| Field | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `name` | Yes | Display name (supports multiple configs for same provider) |
|
|
||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
|
||||||
| `models` | Yes | List of model IDs |
|
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
|
|
||||||
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
|
|
||||||
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
|
|
||||||
|
|
||||||
## Model Capability Requirements
|
## Model Capability Requirements
|
||||||
|
|
||||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
||||||
|
|||||||
@@ -22,27 +22,6 @@ cp env.example .env
|
|||||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using server-side model configuration
|
|
||||||
|
|
||||||
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
|
|
||||||
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
|
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# よくある質問 (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. PDFをエクスポートできない
|
|
||||||
|
|
||||||
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
|
|
||||||
|
|
||||||
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
|
|
||||||
|
|
||||||
**解決策**: まず画像(PNG)としてエクスポートし、その後PDFに印刷する
|
|
||||||
|
|
||||||
**関連Issue**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. embed.diagrams.netにアクセスできない(オフライン/イントラネットデプロイ)
|
|
||||||
|
|
||||||
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
|
|
||||||
|
|
||||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
|
||||||
|
|
||||||
**解決策**: ビルド時に `args` で渡す必要があります:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
|
|
||||||
|
|
||||||
**関連Issue**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 自前モデルが思考するだけで描画しない
|
|
||||||
|
|
||||||
**問題**: ローカルデプロイのモデル(Qwen、LiteLLMなど)が思考過程のみを出力し、図表を生成しない
|
|
||||||
|
|
||||||
**考えられる原因**:
|
|
||||||
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
|
|
||||||
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
|
|
||||||
|
|
||||||
**解決策**: tool callingを有効にする、例えばvLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**関連Issue**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 画像アップロード後「画像が提供されていません」と表示される
|
|
||||||
|
|
||||||
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
|
|
||||||
|
|
||||||
**考えられる原因**:
|
|
||||||
1. モデルがビジョン機能をサポートしていない(Kimi K2、DeepSeek、Qwenテキストモデルなど)
|
|
||||||
|
|
||||||
**解決策**:
|
|
||||||
- ビジョン対応モデルを使用:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
|
||||||
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
|
|
||||||
- 最新バージョン(v0.4.9+)にアップデート
|
|
||||||
|
|
||||||
**関連Issue**: #324, #421, #469
|
|
||||||
@@ -37,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [インストール](#インストール)
|
- [インストール](#インストール)
|
||||||
- [デプロイ](#デプロイ)
|
- [デプロイ](#デプロイ)
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||||
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||||
- [よくある質問](#よくある質問)
|
|
||||||
- [スター履歴](#スター履歴)
|
- [スター履歴](#スター履歴)
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
@@ -181,7 +180,7 @@ npm run dev
|
|||||||
|
|
||||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
||||||
|
|
||||||
### Vercelへのデプロイ
|
### Vercelへのデプロイ(推奨)
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -201,13 +200,11 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
@@ -215,10 +212,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
@@ -245,10 +238,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
- メール:me[at]jiang.jp
|
- メール:me[at]jiang.jp
|
||||||
|
|
||||||
## よくある質問
|
|
||||||
|
|
||||||
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
|
|
||||||
|
|
||||||
## スター履歴
|
## スター履歴
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|||||||
@@ -158,19 +158,6 @@ AI_MODEL=llama3.2
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
|
|
||||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
||||||
@@ -217,63 +204,6 @@ AI_MODEL=openai/gpt-4o
|
|||||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||||
```
|
```
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
|
|
||||||
|
|
||||||
### 設定方法
|
|
||||||
|
|
||||||
**方法1:環境変数**(クラウドデプロイ推奨)
|
|
||||||
|
|
||||||
`AI_MODELS_CONFIG` をJSON文字列として設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法2:設定ファイル**
|
|
||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
|
||||||
|
|
||||||
### 設定例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### フィールド説明
|
|
||||||
|
|
||||||
| フィールド | 必須 | 説明 |
|
|
||||||
|------------|------|------|
|
|
||||||
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
|
|
||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
|
||||||
| `models` | はい | モデルIDのリスト |
|
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
|
||||||
|
|
||||||
### 備考
|
|
||||||
|
|
||||||
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名(例:`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
|
|
||||||
- `name` フィールドにより同一プロバイダーの複数設定が可能です(例:「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
|
|
||||||
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
|
|
||||||
|
|
||||||
## モデル性能要件
|
## モデル性能要件
|
||||||
|
|
||||||
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
||||||
|
|||||||
35
electron/electron.d.ts
vendored
35
electron/electron.d.ts
vendored
@@ -25,25 +25,6 @@ interface ApplyPresetResult {
|
|||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Proxy configuration interface */
|
|
||||||
interface ProxyConfig {
|
|
||||||
httpProxy?: string
|
|
||||||
httpsProxy?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of setting proxy */
|
|
||||||
interface SetProxyResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
devMode?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of setting user locale */
|
|
||||||
interface SetUserLocaleResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
@@ -64,14 +45,6 @@ declare global {
|
|||||||
openFile: () => Promise<string | null>
|
openFile: () => Promise<string | null>
|
||||||
/** Save data to file via save dialog */
|
/** Save data to file via save dialog */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
saveFile: (data: string) => Promise<boolean>
|
||||||
/** Get proxy configuration */
|
|
||||||
getProxy: () => Promise<ProxyConfig>
|
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
|
||||||
/** Get user's preferred locale */
|
|
||||||
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
|
|
||||||
/** Set user's preferred locale */
|
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -98,10 +71,4 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export { ConfigPreset, ApplyPresetResult }
|
||||||
ConfigPreset,
|
|
||||||
ApplyPresetResult,
|
|
||||||
ProxyConfig,
|
|
||||||
SetProxyResult,
|
|
||||||
SetUserLocaleResult,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import {
|
|||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { showSettingsWindow } from "./settings-window"
|
import { showSettingsWindow } from "./settings-window"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu with i18n support
|
* Build and set the application menu
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -26,22 +25,18 @@ export function buildAppMenu(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the menu (call this when presets change or language changes)
|
* Rebuild the menu (call this when presets change)
|
||||||
*/
|
*/
|
||||||
export function rebuildAppMenu(): void {
|
export function rebuildAppMenu(): void {
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template with translations
|
* Get the menu template
|
||||||
*/
|
*/
|
||||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
// Get translations for preferred locale (saved preference or system default)
|
|
||||||
const locale = getPreferredLocale(app.getLocale())
|
|
||||||
const t = getMenuTranslations(locale)
|
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = []
|
const template: MenuItemConstructorOptions[] = []
|
||||||
|
|
||||||
// macOS app menu
|
// macOS app menu
|
||||||
@@ -49,10 +44,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" }, // System-translated
|
{ role: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: t.settings,
|
label: "Settings...",
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -60,26 +55,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "services" }, // System-translated
|
{ role: "services" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" }, // System-translated
|
{ role: "hide" },
|
||||||
{ role: "hideOthers" }, // System-translated
|
{ role: "hideOthers" },
|
||||||
{ role: "unhide" }, // System-translated
|
{ role: "unhide" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" }, // System-translated
|
{ role: "quit" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.file,
|
label: "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
...(isMac
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: t.settings,
|
label: "Settings",
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -88,76 +83,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
isMac ? { role: "close" } : { role: "quit" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.edit,
|
label: "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" }, // System-translated
|
{ role: "undo" },
|
||||||
{ role: "redo" }, // System-translated
|
{ role: "redo" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "cut" }, // System-translated
|
{ role: "cut" },
|
||||||
{ role: "copy" }, // System-translated
|
{ role: "copy" },
|
||||||
{ role: "paste" }, // System-translated
|
{ role: "paste" },
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
role: "pasteAndMatchStyle",
|
role: "pasteAndMatchStyle",
|
||||||
} as MenuItemConstructorOptions, // System-translated
|
} as MenuItemConstructorOptions,
|
||||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
{ role: "delete" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
{ role: "delete" } as MenuItemConstructorOptions,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.view,
|
label: "View",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" }, // System-translated
|
{ role: "reload" },
|
||||||
{ role: "forceReload" }, // System-translated
|
{ role: "forceReload" },
|
||||||
{ role: "toggleDevTools" }, // System-translated
|
{ role: "toggleDevTools" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "resetZoom" }, // System-translated
|
{ role: "resetZoom" },
|
||||||
{ role: "zoomIn" }, // System-translated
|
{ role: "zoomIn" },
|
||||||
{ role: "zoomOut" }, // System-translated
|
{ role: "zoomOut" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" }, // System-translated
|
{ role: "togglefullscreen" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
// Configuration menu with presets
|
||||||
template.push(buildConfigMenu(t))
|
template.push(buildConfigMenu())
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.window,
|
label: "Window",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "minimize" }, // System-translated
|
{ role: "minimize" },
|
||||||
{ role: "zoom" }, // System-translated
|
{ role: "zoom" },
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
{ role: "front" } as MenuItemConstructorOptions,
|
||||||
]
|
]
|
||||||
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
: [{ role: "close" } as MenuItemConstructorOptions]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.help,
|
label: "Help",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: t.documentation,
|
label: "Documentation",
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||||
@@ -165,7 +160,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t.reportIssue,
|
label: "Report Issue",
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||||
@@ -181,9 +176,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
/**
|
/**
|
||||||
* Build the Configuration menu with presets
|
* Build the Configuration menu with presets
|
||||||
*/
|
*/
|
||||||
function buildConfigMenu(
|
function buildConfigMenu(): MenuItemConstructorOptions {
|
||||||
t: ReturnType<typeof getMenuTranslations>,
|
|
||||||
): MenuItemConstructorOptions {
|
|
||||||
const presets = getAllPresets()
|
const presets = getAllPresets()
|
||||||
const currentPresetId = getCurrentPresetId()
|
const currentPresetId = getCurrentPresetId()
|
||||||
|
|
||||||
@@ -223,11 +216,11 @@ function buildConfigMenu(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: t.configuration,
|
label: "Configuration",
|
||||||
submenu: [
|
submenu: [
|
||||||
...(presetItems.length > 0
|
...(presetItems.length > 0
|
||||||
? [
|
? [
|
||||||
{ label: t.switchPreset, enabled: false },
|
{ label: "Switch Preset", enabled: false },
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
...presetItems,
|
...presetItems,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
@@ -236,8 +229,8 @@ function buildConfigMenu(
|
|||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
presetItems.length > 0
|
presetItems.length > 0
|
||||||
? t.managePresets
|
? "Manage Presets..."
|
||||||
: t.addConfigurationPreset,
|
: "Add Configuration Preset...",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
showSettingsWindow(win || undefined)
|
showSettingsWindow(win || undefined)
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ interface ConfigPresetsFile {
|
|||||||
version: 1
|
version: 1
|
||||||
currentPresetId: string | null
|
currentPresetId: string | null
|
||||||
presets: ConfigPreset[]
|
presets: ConfigPreset[]
|
||||||
userLocale?: "en" | "zh" | "ja"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
const CONFIG_FILE_NAME = "config-presets.json"
|
||||||
@@ -162,7 +161,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
userLocale: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +181,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
userLocale: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,10 +351,6 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
apiKey: "SILICONFLOW_API_KEY",
|
apiKey: "SILICONFLOW_API_KEY",
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
baseUrl: "SILICONFLOW_BASE_URL",
|
||||||
},
|
},
|
||||||
modelscope: {
|
|
||||||
apiKey: "MODELSCOPE_API_KEY",
|
|
||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
|
||||||
},
|
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock and ollama don't use API keys in the same way
|
// bedrock and ollama don't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
@@ -465,21 +458,3 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's preferred locale from config
|
|
||||||
* Returns undefined if not set
|
|
||||||
*/
|
|
||||||
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.userLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user's preferred locale in config
|
|
||||||
*/
|
|
||||||
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
|
|
||||||
const data = loadPresets()
|
|
||||||
data.userLocale = locale === null ? undefined : locale
|
|
||||||
savePresets(data)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getCurrentPresetEnv } from "./config-manager"
|
|||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import { createWindow, getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
@@ -25,9 +24,6 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
loadEnvFile()
|
||||||
|
|
||||||
// Apply proxy settings from saved config
|
|
||||||
applyProxyToEnv()
|
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
// Apply saved preset environment variables (overrides .env)
|
||||||
const presetEnv = getCurrentPresetEnv()
|
const presetEnv = getCurrentPresetEnv()
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
for (const [key, value] of Object.entries(presetEnv)) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||||
import { rebuildAppMenu } from "./app-menu"
|
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
applyPresetToEnv,
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
@@ -8,18 +7,10 @@ import {
|
|||||||
getAllPresets,
|
getAllPresets,
|
||||||
getCurrentPreset,
|
getCurrentPreset,
|
||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
getUserLocale,
|
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
setUserLocale,
|
|
||||||
updatePreset,
|
updatePreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import {
|
|
||||||
applyProxyToEnv,
|
|
||||||
getProxyConfig,
|
|
||||||
type ProxyConfig,
|
|
||||||
saveProxyConfig,
|
|
||||||
} from "./proxy-manager"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -218,68 +209,4 @@ export function registerIpcHandlers(): void {
|
|||||||
return setCurrentPreset(id)
|
return setCurrentPreset(id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
|
||||||
|
|
||||||
ipcMain.handle("get-proxy", () => {
|
|
||||||
return getProxyConfig()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
|
||||||
try {
|
|
||||||
// Save config to file
|
|
||||||
saveProxyConfig(config)
|
|
||||||
|
|
||||||
// Apply to current process environment
|
|
||||||
applyProxyToEnv()
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development, env vars are already applied
|
|
||||||
// Next.js dev server may need manual restart
|
|
||||||
return { success: true, devMode: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production: restart Next.js server to pick up new env vars
|
|
||||||
await restartNextServer()
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to apply proxy settings",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== User Locale ====================
|
|
||||||
|
|
||||||
ipcMain.handle("get-user-locale", () => {
|
|
||||||
return getUserLocale()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
|
||||||
// Validate locale is one of the supported values
|
|
||||||
if (!["en", "zh", "ja"].includes(locale)) {
|
|
||||||
return { success: false, error: "Invalid locale" }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUserLocale(locale as "en" | "zh" | "ja")
|
|
||||||
// Rebuild the menu to reflect the new locale
|
|
||||||
rebuildAppMenu()
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to set locale",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Internationalization support for Electron menu
|
|
||||||
* Translations for menu labels that don't use Electron's built-in roles
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getUserLocale } from "./config-manager"
|
|
||||||
|
|
||||||
export type MenuLocale = "en" | "zh" | "ja"
|
|
||||||
|
|
||||||
export interface MenuTranslations {
|
|
||||||
// App menu (macOS only)
|
|
||||||
settings: string
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: string
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: string
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: string
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: string
|
|
||||||
switchPreset: string
|
|
||||||
managePresets: string
|
|
||||||
addConfigurationPreset: string
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: string
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: string
|
|
||||||
documentation: string
|
|
||||||
reportIssue: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
|
||||||
en: {
|
|
||||||
// App menu
|
|
||||||
settings: "Settings...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "File",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "Edit",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "View",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "Configuration",
|
|
||||||
switchPreset: "Switch Preset",
|
|
||||||
managePresets: "Manage Presets...",
|
|
||||||
addConfigurationPreset: "Add Configuration Preset...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "Window",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "Help",
|
|
||||||
documentation: "Documentation",
|
|
||||||
reportIssue: "Report Issue",
|
|
||||||
},
|
|
||||||
|
|
||||||
zh: {
|
|
||||||
// App menu
|
|
||||||
settings: "设置...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "文件",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "编辑",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "查看",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切换预设",
|
|
||||||
managePresets: "管理预设...",
|
|
||||||
addConfigurationPreset: "添加配置预设...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "窗口",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "帮助",
|
|
||||||
documentation: "文档",
|
|
||||||
reportIssue: "报告问题",
|
|
||||||
},
|
|
||||||
|
|
||||||
ja: {
|
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "ファイル",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "編集",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "表示",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "設定",
|
|
||||||
switchPreset: "プリセット切り替え",
|
|
||||||
managePresets: "プリセット管理...",
|
|
||||||
addConfigurationPreset: "設定プリセットを追加...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "ウインドウ",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "ヘルプ",
|
|
||||||
documentation: "ドキュメント",
|
|
||||||
reportIssue: "問題を報告",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get menu translations for a given locale
|
|
||||||
* Falls back to English if locale is not supported
|
|
||||||
*/
|
|
||||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
|
||||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
|
||||||
const normalized = locale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return translations.zh
|
|
||||||
if (normalized === "ja") return translations.ja
|
|
||||||
return translations.en
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect system locale from Electron app
|
|
||||||
* Returns one of: "en", "zh", "ja"
|
|
||||||
*/
|
|
||||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
|
||||||
const normalized = appLocale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return "zh"
|
|
||||||
if (normalized === "ja") return "ja"
|
|
||||||
return "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get locale from stored preference or system default
|
|
||||||
* Checks config file for user's language preference first
|
|
||||||
*/
|
|
||||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
|
||||||
// Try to get from saved preference first
|
|
||||||
const savedLocale = getUserLocale()
|
|
||||||
if (savedLocale) {
|
|
||||||
return savedLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to system locale
|
|
||||||
return detectSystemLocale(appLocale)
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,6 @@ export async function startNextServer(): Promise<string> {
|
|||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "localhost",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
|
||||||
NODE_USE_ENV_PROXY: "1",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
@@ -87,13 +85,6 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: log proxy-related env vars
|
|
||||||
console.log("Proxy env vars being passed to server:", {
|
|
||||||
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
|
|
||||||
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
|
|
||||||
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use Electron's utilityProcess API for running Node.js in background
|
// Use Electron's utilityProcess API for running Node.js in background
|
||||||
// This is the recommended way to run Node.js code in Electron
|
// This is the recommended way to run Node.js code in Electron
|
||||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||||
@@ -123,41 +114,13 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the Next.js server process and wait for it to exit
|
* Stop the Next.js server process
|
||||||
*/
|
*/
|
||||||
export async function stopNextServer(): Promise<void> {
|
export function stopNextServer(): void {
|
||||||
if (serverProcess) {
|
if (serverProcess) {
|
||||||
console.log("Stopping Next.js server...")
|
console.log("Stopping Next.js server...")
|
||||||
|
|
||||||
// Create a promise that resolves when the process exits
|
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
|
||||||
const proc = serverProcess
|
|
||||||
if (!proc) {
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onExit = () => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.once("exit", onExit)
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
proc.removeListener("exit", onExit)
|
|
||||||
resolve()
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
serverProcess.kill()
|
serverProcess.kill()
|
||||||
serverProcess = null
|
serverProcess = null
|
||||||
|
|
||||||
// Wait for process to exit
|
|
||||||
await exitPromise
|
|
||||||
|
|
||||||
// Additional wait for OS to release port
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +150,8 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
export async function restartNextServer(): Promise<string> {
|
export async function restartNextServer(): Promise<string> {
|
||||||
console.log("Restarting Next.js server...")
|
console.log("Restarting Next.js server...")
|
||||||
|
|
||||||
// Stop the current server and wait for it to exit
|
// Stop the current server
|
||||||
await stopNextServer()
|
stopNextServer()
|
||||||
|
|
||||||
// Wait for the port to be released
|
// Wait for the port to be released
|
||||||
await waitForServerStop()
|
await waitForServerStop()
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { app } from "electron"
|
|
||||||
import * as fs from "fs"
|
|
||||||
import * as path from "path"
|
|
||||||
import type { ProxyConfig } from "../electron.d"
|
|
||||||
|
|
||||||
export type { ProxyConfig }
|
|
||||||
|
|
||||||
const CONFIG_FILE = "proxy-config.json"
|
|
||||||
|
|
||||||
function getConfigPath(): string {
|
|
||||||
return path.join(app.getPath("userData"), CONFIG_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load proxy configuration from JSON file
|
|
||||||
*/
|
|
||||||
export function loadProxyConfig(): ProxyConfig {
|
|
||||||
try {
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
const data = fs.readFileSync(configPath, "utf-8")
|
|
||||||
return JSON.parse(data) as ProxyConfig
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load proxy config:", error)
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save proxy configuration to JSON file
|
|
||||||
*/
|
|
||||||
export function saveProxyConfig(config: ProxyConfig): void {
|
|
||||||
try {
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save proxy config:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply proxy configuration to process.env
|
|
||||||
* Must be called BEFORE starting the Next.js server
|
|
||||||
*/
|
|
||||||
export function applyProxyToEnv(): void {
|
|
||||||
const config = loadProxyConfig()
|
|
||||||
|
|
||||||
if (config.httpProxy) {
|
|
||||||
process.env.HTTP_PROXY = config.httpProxy
|
|
||||||
process.env.http_proxy = config.httpProxy
|
|
||||||
} else {
|
|
||||||
delete process.env.HTTP_PROXY
|
|
||||||
delete process.env.http_proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.httpsProxy) {
|
|
||||||
process.env.HTTPS_PROXY = config.httpsProxy
|
|
||||||
process.env.https_proxy = config.httpsProxy
|
|
||||||
} else {
|
|
||||||
delete process.env.HTTPS_PROXY
|
|
||||||
delete process.env.https_proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current proxy configuration (from process.env)
|
|
||||||
*/
|
|
||||||
export function getProxyConfig(): ProxyConfig {
|
|
||||||
return {
|
|
||||||
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
|
|
||||||
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,14 +21,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
// File operations
|
// File operations
|
||||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||||
|
|
||||||
// Proxy settings
|
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
|
||||||
ipcRenderer.invoke("set-proxy", config),
|
|
||||||
|
|
||||||
// User locale settings
|
|
||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
|
||||||
setUserLocale: (locale: string) =>
|
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
<option value="siliconflow">SiliconFlow</option>
|
||||||
<option value="modelscope">ModelScope</option>
|
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ function getProviderLabel(provider) {
|
|||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
siliconflow: "SiliconFlow",
|
siliconflow: "SiliconFlow",
|
||||||
modelscope: "ModelScope",
|
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
}
|
}
|
||||||
return labels[provider] || provider
|
return labels[provider] || provider
|
||||||
|
|||||||
24
env.example
24
env.example
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -40,14 +40,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
||||||
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
||||||
|
|
||||||
# Google Vertex AI Configuration (Enterprise GCP)
|
|
||||||
# For enterprise users needing data residency, VPC Service Controls, or GCP integration
|
|
||||||
# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key
|
|
||||||
# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL
|
|
||||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
|
||||||
# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)
|
|
||||||
# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high)
|
|
||||||
|
|
||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
# Configure endpoint using ONE of these methods:
|
# Configure endpoint using ONE of these methods:
|
||||||
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
||||||
@@ -80,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SGLANG_API_KEY=your-sglang-api-key
|
# SGLANG_API_KEY=your-sglang-api-key
|
||||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||||
|
|
||||||
# ModelScope Configuration
|
|
||||||
# MODELSCOPE_API_KEY=ms-...
|
|
||||||
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
|
|
||||||
|
|
||||||
# ByteDance Doubao Configuration (via Volcengine)
|
# ByteDance Doubao Configuration (via Volcengine)
|
||||||
# DOUBAO_API_KEY=your-doubao-api-key
|
# DOUBAO_API_KEY=your-doubao-api-key
|
||||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||||
@@ -101,11 +89,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
||||||
|
|
||||||
# Optional server-side multi-model configuration
|
|
||||||
# If set, points to a JSON file with server-provided models (see README for schema).
|
|
||||||
# Default: ./ai-models.json in project root
|
|
||||||
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
|
|
||||||
|
|
||||||
# Temperature (Optional)
|
# Temperature (Optional)
|
||||||
# Controls randomness in AI responses. Lower = more deterministic.
|
# Controls randomness in AI responses. Lower = more deterministic.
|
||||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||||
@@ -129,8 +112,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
# ENABLE_PDF_INPUT=true
|
# ENABLE_PDF_INPUT=true
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
# Security Settings (Optional)
|
|
||||||
# Allow private/internal URLs for reverse proxy setups (default: true)
|
|
||||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
|
||||||
# ALLOW_PRIVATE_URLS=false
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
|
||||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
@@ -30,6 +29,12 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
|||||||
|
|
||||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||||
|
|
||||||
|
interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface UseDiagramToolHandlersParams {
|
interface UseDiagramToolHandlersParams {
|
||||||
partialXmlRef: MutableRefObject<string>
|
partialXmlRef: MutableRefObject<string>
|
||||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import {
|
import {
|
||||||
createEmptyConfig,
|
createEmptyConfig,
|
||||||
@@ -133,56 +132,14 @@ export interface UseModelConfigReturn {
|
|||||||
export function useModelConfig(): UseModelConfigReturn {
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
|
|
||||||
const [serverLoaded, setServerLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Load client config on mount
|
// Load config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadConfig()
|
const loaded = loadConfig()
|
||||||
setConfig(loaded)
|
setConfig(loaded)
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load server models on mount (if any)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
|
|
||||||
fetch("/api/server-models")
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error(
|
|
||||||
"Failed to load server models:",
|
|
||||||
res.status,
|
|
||||||
res.statusText,
|
|
||||||
)
|
|
||||||
throw new Error(`Request failed with status ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
const raw: FlattenedServerModel[] = data?.models || []
|
|
||||||
setServerModels(raw)
|
|
||||||
setServerLoaded(true)
|
|
||||||
|
|
||||||
// Auto-select default server model if no model is currently selected
|
|
||||||
setConfig((prev) => {
|
|
||||||
if (!prev.selectedModelId && raw.length > 0) {
|
|
||||||
const defaultModel = raw.find((m) => m.isDefault)
|
|
||||||
if (defaultModel) {
|
|
||||||
return { ...prev, selectedModelId: defaultModel.id }
|
|
||||||
}
|
|
||||||
// If no default marked, use first server model
|
|
||||||
return { ...prev, selectedModelId: raw[0].id }
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error while loading server models:", error)
|
|
||||||
setServerLoaded(true)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Save config whenever it changes (after initial load)
|
// Save config whenever it changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
@@ -191,33 +148,9 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}, [config, isLoaded])
|
}, [config, isLoaded])
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const userModels = flattenModels(config)
|
const models = flattenModels(config)
|
||||||
|
|
||||||
const models: FlattenedModel[] = [
|
|
||||||
// Server models (read-only, credentials from env)
|
|
||||||
...serverModels.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
modelId: m.modelId,
|
|
||||||
provider: m.provider,
|
|
||||||
providerLabel: `Server · ${m.providerLabel}`,
|
|
||||||
apiKey: "",
|
|
||||||
baseUrl: undefined,
|
|
||||||
awsAccessKeyId: undefined,
|
|
||||||
awsSecretAccessKey: undefined,
|
|
||||||
awsRegion: undefined,
|
|
||||||
awsSessionToken: undefined,
|
|
||||||
validated: true,
|
|
||||||
source: "server" as const,
|
|
||||||
isDefault: m.isDefault,
|
|
||||||
apiKeyEnv: m.apiKeyEnv,
|
|
||||||
baseUrlEnv: m.baseUrlEnv,
|
|
||||||
})),
|
|
||||||
// User models from local configuration
|
|
||||||
...userModels,
|
|
||||||
]
|
|
||||||
|
|
||||||
const selectedModel = config.selectedModelId
|
const selectedModel = config.selectedModelId
|
||||||
? models.find((m) => m.id === config.selectedModelId)
|
? findModelById(config, config.selectedModelId)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -349,7 +282,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoaded: isLoaded && serverLoaded,
|
isLoaded,
|
||||||
models,
|
models,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
@@ -381,10 +314,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: string
|
awsSecretAccessKey: string
|
||||||
awsRegion: string
|
awsRegion: string
|
||||||
awsSessionToken: string
|
awsSessionToken: string
|
||||||
// Selected model ID (for server model lookup)
|
|
||||||
selectedModelId: string
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: string
|
|
||||||
} {
|
} {
|
||||||
const empty = {
|
const empty = {
|
||||||
accessCode: "",
|
accessCode: "",
|
||||||
@@ -396,8 +325,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
|
||||||
vertexApiKey: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
if (typeof window === "undefined") return empty
|
||||||
@@ -420,8 +347,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
|
||||||
vertexApiKey: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,32 +357,12 @@ export function getSelectedAIConfig(): {
|
|||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
|
// No selected model = use server default
|
||||||
if (!config.selectedModelId) {
|
if (!config.selectedModelId) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side model selection (id = "server:<name-slug>:<modelId>")
|
// Find selected model
|
||||||
// Provider is resolved server-side via findServerModelById()
|
|
||||||
if (config.selectedModelId.startsWith("server:")) {
|
|
||||||
const parts = config.selectedModelId.split(":")
|
|
||||||
const nameSlug = parts[1] || ""
|
|
||||||
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
|
|
||||||
|
|
||||||
return {
|
|
||||||
...empty,
|
|
||||||
accessCode,
|
|
||||||
// Note: nameSlug is NOT the provider, but we send it for backwards compat
|
|
||||||
// Server uses selectedModelId to lookup the actual provider
|
|
||||||
aiProvider: nameSlug,
|
|
||||||
aiBaseUrl: "",
|
|
||||||
aiApiKey: "",
|
|
||||||
aiModel: modelId,
|
|
||||||
selectedModelId: config.selectedModelId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find selected user-defined model
|
|
||||||
const model = findModelById(config, config.selectedModelId)
|
const model = findModelById(config, config.selectedModelId)
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
@@ -474,8 +379,5 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||||
awsRegion: model.awsRegion || "",
|
awsRegion: model.awsRegion || "",
|
||||||
awsSessionToken: model.awsSessionToken || "",
|
awsSessionToken: model.awsSessionToken || "",
|
||||||
selectedModelId: config.selectedModelId || "",
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: model.vertexApiKey || "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
lib/ai-config.ts
Normal file
26
lib/ai-config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { STORAGE_KEYS } from "./storage"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI configuration from localStorage.
|
||||||
|
* Returns API keys and settings for custom AI providers.
|
||||||
|
* Used to override server defaults when user provides their own API key.
|
||||||
|
*/
|
||||||
|
export function getAIConfig() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return {
|
||||||
|
accessCode: "",
|
||||||
|
aiProvider: "",
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiModel: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||||
|
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
|
||||||
|
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
|
||||||
|
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
|
||||||
|
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,25 @@ import { azure, createAzure } from "@ai-sdk/azure"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway, gateway } from "@ai-sdk/gateway"
|
import { createGateway, gateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export type { ProviderName }
|
export type ProviderName =
|
||||||
|
| "bedrock"
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "google"
|
||||||
|
| "azure"
|
||||||
|
| "ollama"
|
||||||
|
| "openrouter"
|
||||||
|
| "deepseek"
|
||||||
|
| "siliconflow"
|
||||||
|
| "sglang"
|
||||||
|
| "gateway"
|
||||||
|
| "edgeone"
|
||||||
|
| "doubao"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -30,13 +41,8 @@ export interface ClientOverrides {
|
|||||||
awsSecretAccessKey?: string | null
|
awsSecretAccessKey?: string | null
|
||||||
awsRegion?: string | null
|
awsRegion?: string | null
|
||||||
awsSessionToken?: string | null
|
awsSessionToken?: string | null
|
||||||
// Vertex AI config
|
|
||||||
vertexApiKey?: string | null // Express Mode API key
|
|
||||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
// Custom env var names for server models (allows multiple API keys per provider)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -44,7 +50,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"vertexai",
|
|
||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
@@ -54,7 +59,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"gateway",
|
"gateway",
|
||||||
"edgeone",
|
"edgeone",
|
||||||
"doubao",
|
"doubao",
|
||||||
"modelscope",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -69,62 +73,6 @@ const ANTHROPIC_BETA_HEADERS = {
|
|||||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve baseURL based on whether user is providing their own API key.
|
|
||||||
* When user provides their own API key, we should NOT fall back to server's
|
|
||||||
* baseURL environment variable - user credentials should only be sent to
|
|
||||||
* user-specified endpoints or official provider endpoints.
|
|
||||||
*
|
|
||||||
* @param userApiKey - User-provided API key (if any)
|
|
||||||
* @param userBaseUrl - User-provided base URL (if any)
|
|
||||||
* @param serverBaseUrl - Server's base URL from environment variable
|
|
||||||
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
|
||||||
* @returns The resolved base URL to use
|
|
||||||
*/
|
|
||||||
export function resolveBaseURL(
|
|
||||||
userApiKey: string | null | undefined,
|
|
||||||
userBaseUrl: string | null | undefined,
|
|
||||||
serverBaseUrl: string | undefined,
|
|
||||||
defaultBaseUrl?: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (userApiKey) {
|
|
||||||
// User provides their own API key - only use user's baseUrl or default
|
|
||||||
return userBaseUrl || defaultBaseUrl || undefined
|
|
||||||
}
|
|
||||||
// No user API key - fall back to server config
|
|
||||||
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve API key from custom env var name or default env var.
|
|
||||||
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. User-provided API key (overrides.apiKey)
|
|
||||||
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
|
|
||||||
* 3. Default provider env var (defaultEnvVar)
|
|
||||||
*/
|
|
||||||
function resolveApiKey(
|
|
||||||
overrides: ClientOverrides | undefined,
|
|
||||||
defaultEnvVar: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (overrides?.apiKey) return overrides.apiKey
|
|
||||||
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
|
|
||||||
return process.env[defaultEnvVar]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve base URL from custom env var name or default env var.
|
|
||||||
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
|
|
||||||
*/
|
|
||||||
function resolveBaseUrlEnv(
|
|
||||||
overrides: ClientOverrides | undefined,
|
|
||||||
defaultEnvVar: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
|
|
||||||
return process.env[defaultEnvVar]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse integer from environment variable with validation
|
* Safely parse integer from environment variable with validation
|
||||||
*/
|
*/
|
||||||
@@ -159,8 +107,6 @@ function parseIntSafe(
|
|||||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
||||||
* - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)
|
|
||||||
* - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)
|
|
||||||
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
||||||
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
||||||
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
||||||
@@ -325,46 +271,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
|
||||||
const thinkingBudget = parseIntSafe(
|
|
||||||
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
|
||||||
"GOOGLE_VERTEX_THINKING_BUDGET",
|
|
||||||
1024,
|
|
||||||
100000,
|
|
||||||
)
|
|
||||||
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
|
||||||
|
|
||||||
if (
|
|
||||||
modelId &&
|
|
||||||
(modelId.includes("gemini-2") ||
|
|
||||||
modelId.includes("gemini-3") ||
|
|
||||||
modelId.includes("gemini2") ||
|
|
||||||
modelId.includes("gemini3"))
|
|
||||||
) {
|
|
||||||
const thinkingConfig: Record<string, any> = {
|
|
||||||
includeThoughts: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGemini3 =
|
|
||||||
modelId?.includes("gemini-3") ||
|
|
||||||
modelId?.includes("gemini3")
|
|
||||||
const isGemini25 =
|
|
||||||
modelId?.includes("2.5") || modelId?.includes("2-5")
|
|
||||||
|
|
||||||
if (isGemini3 && thinkingLevel) {
|
|
||||||
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
|
|
||||||
thinkingConfig.thinkingLevel = thinkingLevel as
|
|
||||||
| "minimal"
|
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
} else if (isGemini25 && thinkingBudget) {
|
|
||||||
thinkingConfig.thinkingBudget = thinkingBudget
|
|
||||||
}
|
|
||||||
options.google = { thinkingConfig }
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
||||||
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
||||||
@@ -446,7 +353,6 @@ function buildProviderOptions(
|
|||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
case "modelscope":
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
@@ -466,7 +372,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
vertexai: "GOOGLE_VERTEX_API_KEY",
|
|
||||||
azure: "AZURE_API_KEY",
|
azure: "AZURE_API_KEY",
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
@@ -476,7 +381,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
||||||
doubao: "DOUBAO_API_KEY",
|
doubao: "DOUBAO_API_KEY",
|
||||||
modelscope: "MODELSCOPE_API_KEY",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -514,15 +418,9 @@ function detectProvider(): ProviderName | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that required API keys are present for the selected provider
|
* Validate that required API keys are present for the selected provider
|
||||||
* @param provider - The provider to validate
|
|
||||||
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
|
|
||||||
*/
|
*/
|
||||||
function validateProviderCredentials(
|
function validateProviderCredentials(provider: ProviderName): void {
|
||||||
provider: ProviderName,
|
const requiredVar = PROVIDER_ENV_VARS[provider]
|
||||||
customApiKeyEnv?: string,
|
|
||||||
): void {
|
|
||||||
// Use custom env var name if provided, otherwise use default
|
|
||||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||||
@@ -547,7 +445,7 @@ function validateProviderCredentials(
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -562,11 +460,9 @@ function validateProviderCredentials(
|
|||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1)
|
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||||
* - SGLANG_API_KEY: SGLang API key
|
* - SGLANG_API_KEY: SGLang API key
|
||||||
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||||
* - MODELSCOPE_API_KEY: ModelScope API key
|
|
||||||
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
|
|
||||||
*/
|
*/
|
||||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
@@ -576,7 +472,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
if (
|
if (
|
||||||
overrides?.baseUrl &&
|
overrides?.baseUrl &&
|
||||||
!overrides?.apiKey &&
|
!overrides?.apiKey &&
|
||||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
|
||||||
overrides?.provider !== "edgeone"
|
overrides?.provider !== "edgeone"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -586,11 +481,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if client is providing their own provider override
|
// Check if client is providing their own provider override
|
||||||
const isClientOverride = !!(
|
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
|
||||||
overrides?.provider &&
|
|
||||||
(overrides?.apiKey ||
|
|
||||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use client override if provided, otherwise fall back to env vars
|
// Use client override if provided, otherwise fall back to env vars
|
||||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||||
@@ -646,7 +537,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
`- MODELSCOPE_API_KEY for ModelScope\n` +
|
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -660,7 +550,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
// Only validate server credentials if client isn't providing their own API key
|
// Only validate server credentials if client isn't providing their own API key
|
||||||
if (!isClientOverride) {
|
if (!isClientOverride) {
|
||||||
validateProviderCredentials(provider, overrides?.apiKeyEnv)
|
validateProviderCredentials(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||||
@@ -710,16 +600,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||||
overrides,
|
|
||||||
"OPENAI_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL) {
|
if (baseURL) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
// Custom base URL = third-party proxy, use Chat Completions API
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
// for compatibility (most proxies don't support /responses endpoint)
|
||||||
@@ -737,17 +619,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"ANTHROPIC_BASE_URL",
|
process.env.ANTHROPIC_BASE_URL ||
|
||||||
)
|
"https://api.anthropic.com/v1"
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api.anthropic.com/v1",
|
|
||||||
)
|
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -760,19 +636,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const apiKey = resolveApiKey(
|
const apiKey =
|
||||||
overrides,
|
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
|
||||||
)
|
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
|
||||||
overrides,
|
|
||||||
"GOOGLE_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customGoogle = createGoogleGenerativeAI({
|
const customGoogle = createGoogleGenerativeAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -784,42 +650,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
|
||||||
// Express Mode: Use API key for authentication
|
|
||||||
const vertexApiKey =
|
|
||||||
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
|
||||||
|
|
||||||
if (!vertexApiKey) {
|
|
||||||
throw new Error(
|
|
||||||
"Vertex AI requires an API key for Express Mode. " +
|
|
||||||
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support custom base URL from env or client override
|
|
||||||
const baseURL =
|
|
||||||
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
|
|
||||||
|
|
||||||
const vertexProvider = createVertex({
|
|
||||||
apiKey: vertexApiKey,
|
|
||||||
...(baseURL && { baseURL }),
|
|
||||||
})
|
|
||||||
model = vertexProvider(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
|
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
||||||
const baseURL = resolveBaseURL(
|
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
// Only use server's resourceName if user is NOT providing their own API key
|
|
||||||
const resourceName = overrides?.apiKey
|
|
||||||
? undefined
|
|
||||||
: process.env.AZURE_RESOURCE_NAME
|
|
||||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
// Azure requires either baseURL or resourceName to construct the endpoint
|
||||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
||||||
if (baseURL || resourceName || overrides?.apiKey) {
|
if (baseURL || resourceName || overrides?.apiKey) {
|
||||||
@@ -848,16 +683,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "openrouter": {
|
case "openrouter": {
|
||||||
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
|
||||||
"OPENROUTER_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
const openrouter = createOpenRouter({
|
const openrouter = createOpenRouter({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
...(baseURL && { baseURL }),
|
||||||
@@ -867,16 +695,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
|
||||||
overrides,
|
|
||||||
"DEEPSEEK_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customDeepSeek = createDeepSeek({
|
const customDeepSeek = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -890,17 +710,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"SILICONFLOW_BASE_URL",
|
process.env.SILICONFLOW_BASE_URL ||
|
||||||
)
|
"https://api.siliconflow.com/v1"
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api.siliconflow.cn/v1",
|
|
||||||
)
|
|
||||||
const siliconflowProvider = createOpenAI({
|
const siliconflowProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -910,20 +724,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "sglang": {
|
case "sglang": {
|
||||||
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
||||||
overrides,
|
|
||||||
"SGLANG_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
const sglangProvider = createOpenAI({
|
const sglangProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
baseURL,
|
||||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||||
fetch: async (url, options) => {
|
fetch: async (url, options) => {
|
||||||
const response = await fetch(url, options)
|
const response = await fetch(url, options)
|
||||||
@@ -1027,16 +833,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
// See: https://vercel.com/ai-gateway
|
// See: https://vercel.com/ai-gateway
|
||||||
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
|
||||||
"AI_GATEWAY_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
@@ -1067,17 +866,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"DOUBAO_BASE_URL",
|
process.env.DOUBAO_BASE_URL ||
|
||||||
)
|
"https://ark.cn-beijing.volces.com/api/v3"
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
)
|
|
||||||
const lowerModelId = modelId.toLowerCase()
|
const lowerModelId = modelId.toLowerCase()
|
||||||
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
||||||
if (
|
if (
|
||||||
@@ -1099,29 +892,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "modelscope": {
|
|
||||||
const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
|
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
|
||||||
overrides,
|
|
||||||
"MODELSCOPE_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api-inference.modelscope.cn/v1",
|
|
||||||
)
|
|
||||||
const modelscopeProvider = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL,
|
|
||||||
})
|
|
||||||
model = modelscopeProvider.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -100,22 +99,14 @@
|
|||||||
"switchTo": "Switch to",
|
"switchTo": "Switch to",
|
||||||
"minimal": "Minimal",
|
"minimal": "Minimal",
|
||||||
"sketch": "Sketch",
|
"sketch": "Sketch",
|
||||||
|
"closeProtection": "Close Protection",
|
||||||
|
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
||||||
"diagramStyle": "Diagram Style",
|
"diagramStyle": "Diagram Style",
|
||||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||||
"sendShortcut": "Send Shortcut",
|
|
||||||
"sendShortcutDescription": "Choose how to send messages.",
|
|
||||||
"enterToSend": "Enter to send",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
|
||||||
"diagramActions": "Diagram Actions",
|
"diagramActions": "Diagram Actions",
|
||||||
"diagramActionsDescription": "Manage diagram history and exports",
|
"diagramActionsDescription": "Manage diagram history and exports",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"download": "Download",
|
"download": "Download"
|
||||||
"proxy": "Proxy Settings",
|
|
||||||
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
|
|
||||||
"httpProxy": "HTTP Proxy",
|
|
||||||
"httpsProxy": "HTTPS Proxy",
|
|
||||||
"applyProxy": "Apply",
|
|
||||||
"proxyApplied": "Proxy settings applied"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "Save Diagram",
|
"title": "Save Diagram",
|
||||||
@@ -201,9 +192,7 @@
|
|||||||
"description": "Paste a URL to extract and analyze its content",
|
"description": "Paste a URL to extract and analyze its content",
|
||||||
"Extracting": "Extracting...",
|
"Extracting": "Extracting...",
|
||||||
"extract": "Extract",
|
"extract": "Extract",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel"
|
||||||
"enterUrl": "Please enter a URL",
|
|
||||||
"invalidFormat": "Invalid URL format"
|
|
||||||
},
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "Thinking...",
|
"thinking": "Thinking...",
|
||||||
@@ -305,13 +294,10 @@
|
|||||||
"noModelsFound": "No models found.",
|
"noModelsFound": "No models found.",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"serverDefault": "Server Default",
|
"serverDefault": "Server Default",
|
||||||
"serverModels": "Server Models",
|
|
||||||
"userModels": "User Models",
|
|
||||||
"configureModels": "Configure Models...",
|
"configureModels": "Configure Models...",
|
||||||
"onlyVerifiedShown": "Only verified models are shown",
|
"onlyVerifiedShown": "Only verified models are shown",
|
||||||
"showUnvalidatedModels": "Show unvalidated models",
|
"showUnvalidatedModels": "Show unvalidated models",
|
||||||
"allModelsShown": "All models are shown (including unvalidated)",
|
"allModelsShown": "All models are shown (including unvalidated)",
|
||||||
"unvalidatedModelWarning": "This model has not been validated",
|
"unvalidatedModelWarning": "This model has not been validated"
|
||||||
"serverDefaultModel": "Server default model"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -100,22 +99,14 @@
|
|||||||
"switchTo": "切り替え",
|
"switchTo": "切り替え",
|
||||||
"minimal": "ミニマル",
|
"minimal": "ミニマル",
|
||||||
"sketch": "スケッチ",
|
"sketch": "スケッチ",
|
||||||
|
"closeProtection": "ページ離脱確認",
|
||||||
|
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
||||||
"diagramStyle": "ダイアグラムスタイル",
|
"diagramStyle": "ダイアグラムスタイル",
|
||||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||||
"sendShortcut": "送信ショートカット",
|
|
||||||
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
|
||||||
"enterToSend": "Enterで送信",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
|
||||||
"diagramActions": "ダイアグラム操作",
|
"diagramActions": "ダイアグラム操作",
|
||||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード"
|
||||||
"proxy": "プロキシ設定",
|
|
||||||
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
|
|
||||||
"httpProxy": "HTTP プロキシ",
|
|
||||||
"httpsProxy": "HTTPS プロキシ",
|
|
||||||
"applyProxy": "適用",
|
|
||||||
"proxyApplied": "プロキシ設定が適用されました"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -201,9 +192,7 @@
|
|||||||
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
||||||
"Extracting": "抽出中...",
|
"Extracting": "抽出中...",
|
||||||
"extract": "抽出",
|
"extract": "抽出",
|
||||||
"Cancel": "キャンセル",
|
"Cancel": "キャンセル"
|
||||||
"enterUrl": "URLを入力してください",
|
|
||||||
"invalidFormat": "無効なURL形式です"
|
|
||||||
},
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "考え中...",
|
"thinking": "考え中...",
|
||||||
@@ -305,13 +294,10 @@
|
|||||||
"noModelsFound": "モデルが見つかりません。",
|
"noModelsFound": "モデルが見つかりません。",
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"serverDefault": "サーバーデフォルト",
|
"serverDefault": "サーバーデフォルト",
|
||||||
"serverModels": "サーバーモデル",
|
|
||||||
"userModels": "ユーザーモデル",
|
|
||||||
"configureModels": "モデルを設定...",
|
"configureModels": "モデルを設定...",
|
||||||
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
"unvalidatedModelWarning": "このモデルは検証されていません"
|
||||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -100,22 +99,14 @@
|
|||||||
"switchTo": "切换到",
|
"switchTo": "切换到",
|
||||||
"minimal": "简约",
|
"minimal": "简约",
|
||||||
"sketch": "草图",
|
"sketch": "草图",
|
||||||
|
"closeProtection": "关闭确认",
|
||||||
|
"closeProtectionDescription": "离开页面时显示确认。",
|
||||||
"diagramStyle": "图表样式",
|
"diagramStyle": "图表样式",
|
||||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||||
"sendShortcut": "发送快捷键",
|
|
||||||
"sendShortcutDescription": "选择发送消息的方式。",
|
|
||||||
"enterToSend": "回车发送",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
|
|
||||||
"diagramActions": "图表操作",
|
"diagramActions": "图表操作",
|
||||||
"diagramActionsDescription": "管理图表历史记录和导出",
|
"diagramActionsDescription": "管理图表历史记录和导出",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"download": "下载",
|
"download": "下载"
|
||||||
"proxy": "代理设置",
|
|
||||||
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
|
|
||||||
"httpProxy": "HTTP 代理",
|
|
||||||
"httpsProxy": "HTTPS 代理",
|
|
||||||
"applyProxy": "应用",
|
|
||||||
"proxyApplied": "代理设置已应用"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -201,9 +192,7 @@
|
|||||||
"description": "粘贴 URL 以提取和分析其内容",
|
"description": "粘贴 URL 以提取和分析其内容",
|
||||||
"Extracting": "提取中...",
|
"Extracting": "提取中...",
|
||||||
"extract": "提取",
|
"extract": "提取",
|
||||||
"Cancel": "取消",
|
"Cancel": "取消"
|
||||||
"enterUrl": "请输入 URL",
|
|
||||||
"invalidFormat": "URL 格式无效"
|
|
||||||
},
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "思考中...",
|
"thinking": "思考中...",
|
||||||
@@ -305,13 +294,10 @@
|
|||||||
"noModelsFound": "未找到模型。",
|
"noModelsFound": "未找到模型。",
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
"serverDefault": "服务器默认",
|
"serverDefault": "服务器默认",
|
||||||
"serverModels": "服务器模型",
|
|
||||||
"userModels": "用户模型",
|
|
||||||
"configureModels": "配置模型...",
|
"configureModels": "配置模型...",
|
||||||
"onlyVerifiedShown": "仅显示已验证的模型",
|
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||||
"showUnvalidatedModels": "显示未验证的模型",
|
"showUnvalidatedModels": "显示未验证的模型",
|
||||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||||
"unvalidatedModelWarning": "此模型尚未验证",
|
"unvalidatedModelWarning": "此模型尚未验证"
|
||||||
"serverDefaultModel": "服务器默认模型"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { z } from "zod"
|
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { PROVIDER_INFO } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
|
||||||
.string()
|
|
||||||
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
|
|
||||||
message: "Invalid provider name",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ServerProviderSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
provider: ProviderNameSchema,
|
|
||||||
models: z.array(z.string().min(1)),
|
|
||||||
// Optional: custom environment variable name for API key
|
|
||||||
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
|
|
||||||
apiKeyEnv: z.string().min(1).optional(),
|
|
||||||
// Optional: custom environment variable name for base URL
|
|
||||||
baseUrlEnv: z.string().min(1).optional(),
|
|
||||||
// Optional: mark the first model in this provider as the default
|
|
||||||
default: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ServerModelsConfigSchema = z.object({
|
|
||||||
providers: z.array(ServerProviderSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
|
|
||||||
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
|
|
||||||
|
|
||||||
export interface FlattenedServerModel {
|
|
||||||
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
|
|
||||||
modelId: string
|
|
||||||
provider: ProviderName
|
|
||||||
providerLabel: string
|
|
||||||
isDefault: boolean
|
|
||||||
// Custom env var names for credentials (optional)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert provider name to URL-safe slug for use in model ID
|
|
||||||
* e.g., "OpenAI Production" → "openai-production"
|
|
||||||
*/
|
|
||||||
function slugify(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfigPath(): string {
|
|
||||||
const custom = process.env.AI_MODELS_CONFIG_PATH
|
|
||||||
if (custom && custom.trim().length > 0) return custom
|
|
||||||
return path.join(process.cwd(), "ai-models.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
|
||||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
|
||||||
const envConfig = process.env.AI_MODELS_CONFIG
|
|
||||||
if (envConfig && envConfig.trim().length > 0) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(envConfig)
|
|
||||||
return ServerModelsConfigSchema.parse(json)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: ai-models.json file
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
try {
|
|
||||||
const jsonStr = await fs.readFile(configPath, "utf8")
|
|
||||||
const json = JSON.parse(jsonStr)
|
|
||||||
return ServerModelsConfigSchema.parse(json)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ENOENT") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
"[server-model-config] Failed to load ai-models.json:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadFlattenedServerModels(): Promise<
|
|
||||||
FlattenedServerModel[]
|
|
||||||
> {
|
|
||||||
const cfg = await loadRawServerModelsConfig()
|
|
||||||
if (!cfg) return []
|
|
||||||
|
|
||||||
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
|
|
||||||
const defaultModelId = process.env.AI_MODEL
|
|
||||||
|
|
||||||
const flattened: FlattenedServerModel[] = []
|
|
||||||
|
|
||||||
for (const p of cfg.providers) {
|
|
||||||
const providerLabel =
|
|
||||||
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
|
|
||||||
|
|
||||||
// Use slugified name for unique ID (supports multiple API keys per provider)
|
|
||||||
const nameSlug = slugify(p.name)
|
|
||||||
|
|
||||||
for (const modelId of p.models) {
|
|
||||||
const id = `server:${nameSlug}:${modelId}`
|
|
||||||
|
|
||||||
// Default model priority:
|
|
||||||
// 1. From ai-models.json: first model of provider with default: true
|
|
||||||
// 2. From env vars: AI_MODEL matches (legacy behavior)
|
|
||||||
const isDefault =
|
|
||||||
(p.default === true && modelId === p.models[0]) ||
|
|
||||||
(!!defaultModelId &&
|
|
||||||
modelId === defaultModelId &&
|
|
||||||
(!defaultProvider || defaultProvider === p.provider))
|
|
||||||
|
|
||||||
flattened.push({
|
|
||||||
id,
|
|
||||||
modelId,
|
|
||||||
provider: p.provider,
|
|
||||||
providerLabel,
|
|
||||||
isDefault,
|
|
||||||
apiKeyEnv: p.apiKeyEnv,
|
|
||||||
baseUrlEnv: p.baseUrlEnv,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return flattened
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
|
|
||||||
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
|
|
||||||
*/
|
|
||||||
export async function findServerModelById(
|
|
||||||
modelId: string,
|
|
||||||
): Promise<FlattenedServerModel | null> {
|
|
||||||
if (!modelId.startsWith("server:")) return null
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
return models.find((m) => m.id === modelId) || null
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSRF (Server-Side Request Forgery) protection utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL points to private/internal network
|
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
|
||||||
*/
|
|
||||||
export function isPrivateUrl(urlString: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
const hostname = url.hostname.toLowerCase()
|
|
||||||
|
|
||||||
// Block localhost
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "::1"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block AWS/cloud metadata endpoints
|
|
||||||
if (
|
|
||||||
hostname === "169.254.169.254" ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private IPv4 ranges
|
|
||||||
const ipv4Match = hostname.match(
|
|
||||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
||||||
)
|
|
||||||
if (ipv4Match) {
|
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
|
||||||
if (a === 10) return true // 10.0.0.0/8
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
|
||||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
|
||||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
|
||||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block common internal hostnames
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".localhost")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
return true // Invalid URL - block it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether private URLs are allowed (defaults to true)
|
|
||||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
|
||||||
*/
|
|
||||||
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
|
||||||
@@ -12,6 +12,7 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
accessCode: "next-ai-draw-io-access-code",
|
accessCode: "next-ai-draw-io-access-code",
|
||||||
|
closeProtection: "next-ai-draw-io-close-protection",
|
||||||
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
aiProvider: "next-ai-draw-io-ai-provider",
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
@@ -21,7 +22,4 @@ export const STORAGE_KEYS = {
|
|||||||
// Multi-model configuration
|
// Multi-model configuration
|
||||||
modelConfigs: "next-ai-draw-io-model-configs",
|
modelConfigs: "next-ai-draw-io-model-configs",
|
||||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||||
|
|
||||||
// Chat input preferences
|
|
||||||
sendShortcut: "next-ai-draw-io-send-shortcut",
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
39
lib/token-counter.ts
Normal file
39
lib/token-counter.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Token counting utilities using js-tiktoken
|
||||||
|
*
|
||||||
|
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
|
||||||
|
* This is a pure JavaScript implementation, no WASM required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encodingForModel } from "js-tiktoken"
|
||||||
|
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
|
||||||
|
|
||||||
|
const encoder = encodingForModel("gpt-4o")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of tokens in a text string
|
||||||
|
* @param text - The text to count tokens for
|
||||||
|
* @returns The number of tokens
|
||||||
|
*/
|
||||||
|
export function countTextTokens(text: string): number {
|
||||||
|
return encoder.encode(text).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get token counts for the system prompts
|
||||||
|
* Useful for debugging and optimizing prompt sizes
|
||||||
|
* @returns Object with token counts for default and extended prompts
|
||||||
|
*/
|
||||||
|
export function getSystemPromptTokenCounts(): {
|
||||||
|
default: number
|
||||||
|
extended: number
|
||||||
|
additions: number
|
||||||
|
} {
|
||||||
|
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
|
||||||
|
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
|
||||||
|
return {
|
||||||
|
default: defaultTokens,
|
||||||
|
extended: extendedTokens,
|
||||||
|
additions: extendedTokens - defaultTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,8 @@ export type ProviderName =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
| "vertexai"
|
|
||||||
| "azure"
|
| "azure"
|
||||||
| "bedrock"
|
| "bedrock"
|
||||||
| "ollama"
|
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
@@ -15,7 +13,6 @@ export type ProviderName =
|
|||||||
| "gateway"
|
| "gateway"
|
||||||
| "edgeone"
|
| "edgeone"
|
||||||
| "doubao"
|
| "doubao"
|
||||||
| "modelscope"
|
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -37,9 +34,6 @@ export interface ProviderConfig {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string // Optional, for temporary credentials
|
awsSessionToken?: string // Optional, for temporary credentials
|
||||||
// Vertex AI specific fields
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
|
|
||||||
models: ModelConfig[]
|
models: ModelConfig[]
|
||||||
validated?: boolean // Has API key been validated
|
validated?: boolean // Has API key been validated
|
||||||
}
|
}
|
||||||
@@ -54,7 +48,7 @@ export interface MultiModelConfig {
|
|||||||
|
|
||||||
// Flattened model for dropdown display
|
// Flattened model for dropdown display
|
||||||
export interface FlattenedModel {
|
export interface FlattenedModel {
|
||||||
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
|
id: string // Model config UUID
|
||||||
modelId: string // Actual model ID
|
modelId: string // Actual model ID
|
||||||
provider: ProviderName
|
provider: ProviderName
|
||||||
providerLabel: string // Provider display name
|
providerLabel: string // Provider display name
|
||||||
@@ -65,17 +59,7 @@ export interface FlattenedModel {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string
|
awsSessionToken?: string
|
||||||
// Vertex AI specific fields
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
|
|
||||||
validated?: boolean // Has this model been validated
|
validated?: boolean // Has this model been validated
|
||||||
// Source of this model config: user-defined (client) or server-defined
|
|
||||||
source?: "user" | "server"
|
|
||||||
// Whether this model is the server default (matches AI_MODEL env var)
|
|
||||||
isDefault?: boolean
|
|
||||||
// Custom env var names for server models (allows multiple API keys per provider)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -89,18 +73,13 @@ export const PROVIDER_INFO: Record<
|
|||||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||||
},
|
},
|
||||||
google: { label: "Google" },
|
google: { label: "Google" },
|
||||||
vertexai: { label: "Google Vertex AI" },
|
|
||||||
azure: { label: "Azure OpenAI" },
|
azure: { label: "Azure OpenAI" },
|
||||||
bedrock: { label: "Amazon Bedrock" },
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
ollama: {
|
|
||||||
label: "Ollama",
|
|
||||||
defaultBaseUrl: "http://localhost:11434",
|
|
||||||
},
|
|
||||||
openrouter: { label: "OpenRouter" },
|
openrouter: { label: "OpenRouter" },
|
||||||
deepseek: { label: "DeepSeek" },
|
deepseek: { label: "DeepSeek" },
|
||||||
siliconflow: {
|
siliconflow: {
|
||||||
label: "SiliconFlow",
|
label: "SiliconFlow",
|
||||||
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
||||||
},
|
},
|
||||||
sglang: {
|
sglang: {
|
||||||
label: "SGLang",
|
label: "SGLang",
|
||||||
@@ -112,14 +91,10 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "Doubao (ByteDance)",
|
label: "Doubao (ByteDance)",
|
||||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
},
|
},
|
||||||
modelscope: {
|
|
||||||
label: "ModelScope",
|
|
||||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||||
openai: [
|
openai: [
|
||||||
"gpt-5.2-pro",
|
"gpt-5.2-pro",
|
||||||
"gpt-5.2-chat-latest",
|
"gpt-5.2-chat-latest",
|
||||||
@@ -172,17 +147,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
// Legacy
|
// Legacy
|
||||||
"gemini-pro",
|
"gemini-pro",
|
||||||
],
|
],
|
||||||
vertexai: [
|
|
||||||
// Gemini 2.5 series
|
|
||||||
"gemini-2.5-pro",
|
|
||||||
"gemini-2.5-flash",
|
|
||||||
// Gemini 2.0 series
|
|
||||||
"gemini-2.0-flash",
|
|
||||||
"gemini-2.0-flash-exp",
|
|
||||||
// Gemini 1.5 series
|
|
||||||
"gemini-1.5-pro",
|
|
||||||
"gemini-1.5-flash",
|
|
||||||
],
|
|
||||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||||
bedrock: [
|
bedrock: [
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
@@ -267,17 +231,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"doubao-pro-32k-241215",
|
"doubao-pro-32k-241215",
|
||||||
"doubao-pro-256k-241215",
|
"doubao-pro-256k-241215",
|
||||||
],
|
],
|
||||||
modelscope: [
|
|
||||||
// Qwen
|
|
||||||
"Qwen/Qwen2.5-72B-Instruct",
|
|
||||||
"Qwen/Qwen2.5-32B-Instruct",
|
|
||||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
|
||||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
|
||||||
"Qwen/Qwen3-32B",
|
|
||||||
// DeepSeek
|
|
||||||
"deepseek-ai/DeepSeek-R1-0528",
|
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
@@ -314,7 +267,7 @@ export function createModelConfig(modelId: string): ModelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all models as flattened list for dropdown (user-defined only)
|
// Get all models as flattened list for dropdown
|
||||||
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
||||||
const models: FlattenedModel[] = []
|
const models: FlattenedModel[] = []
|
||||||
|
|
||||||
@@ -336,12 +289,7 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
|||||||
awsSecretAccessKey: provider.awsSecretAccessKey,
|
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||||
awsRegion: provider.awsRegion,
|
awsRegion: provider.awsRegion,
|
||||||
awsSessionToken: provider.awsSessionToken,
|
awsSessionToken: provider.awsSessionToken,
|
||||||
// Vertex AI fields
|
|
||||||
vertexApiKey: provider.vertexApiKey,
|
|
||||||
|
|
||||||
validated: model.validated,
|
validated: model.validated,
|
||||||
source: "user",
|
|
||||||
isDefault: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import * as pako from "pako"
|
import * as pako from "pako"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
|
||||||
|
|
||||||
export type { DiagramOperation }
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -476,6 +473,12 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
// ID-based Diagram Operations
|
// ID-based Diagram Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationError {
|
export interface OperationError {
|
||||||
type: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cellId: string
|
cellId: string
|
||||||
|
|||||||
3867
package-lock.json
generated
3867
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.11",
|
"version": "0.4.9",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
|
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
|
||||||
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
||||||
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
||||||
"dist:win:build": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never",
|
|
||||||
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
||||||
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
|
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
"@ai-sdk/deepseek": "^2.0.0",
|
"@ai-sdk/deepseek": "^2.0.0",
|
||||||
"@ai-sdk/gateway": "^3.0.0",
|
"@ai-sdk/gateway": "^3.0.0",
|
||||||
"@ai-sdk/google": "^3.0.0",
|
"@ai-sdk/google": "^3.0.0",
|
||||||
"@ai-sdk/google-vertex": "^4.0.16",
|
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
@@ -48,10 +46,9 @@
|
|||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@opennextjs/cloudflare": "1.14.8",
|
"@opennextjs/cloudflare": "1.14.7",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
|
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -71,10 +68,10 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.7",
|
||||||
"ollama-ai-provider-v2": "^2.0.0",
|
"ollama-ai-provider-v2": "^2.0.0",
|
||||||
@@ -139,7 +136,7 @@
|
|||||||
"vite-tsconfig-paths": "^6.0.3",
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.58.0"
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@openrouter/ai-sdk-provider": {
|
"@openrouter/ai-sdk-provider": {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "next-ai-drawio",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.",
|
|
||||||
"author": {
|
|
||||||
"name": "DayuanJiang"
|
|
||||||
},
|
|
||||||
"repository": "https://github.com/DayuanJiang/next-ai-draw-io",
|
|
||||||
"homepage": "https://next-ai-drawio.jiang.jp",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"drawio": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["@next-ai-drawio/mcp-server@latest"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Next AI Draw.io - Claude Code Plugin
|
|
||||||
|
|
||||||
AI-powered Draw.io diagram generation with real-time browser preview for Claude Code.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From Plugin Directory (Coming Soon)
|
|
||||||
|
|
||||||
Once approved, install via:
|
|
||||||
```
|
|
||||||
/plugin install next-ai-drawio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude --plugin-dir /path/to/packages/claude-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
Or add the MCP server directly:
|
|
||||||
```bash
|
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
|
|
||||||
- **Version History**: Restore previous diagram versions with visual thumbnails
|
|
||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
|
||||||
- **Self-contained**: Embedded server, no external dependencies required
|
|
||||||
|
|
||||||
## Use Case Examples
|
|
||||||
|
|
||||||
### 1. Create Architecture Diagrams
|
|
||||||
|
|
||||||
```
|
|
||||||
Generate an AWS architecture diagram with Lambda, API Gateway, DynamoDB,
|
|
||||||
and S3 for a serverless REST API
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Flowchart Generation
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a flowchart showing the CI/CD pipeline: code commit -> build ->
|
|
||||||
test -> staging deploy -> production deploy with approval gates
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. System Design Documentation
|
|
||||||
|
|
||||||
```
|
|
||||||
Design a microservices e-commerce system with user service, product catalog,
|
|
||||||
shopping cart, order processing, and payment gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Cloud Architecture (AWS/GCP/Azure)
|
|
||||||
|
|
||||||
```
|
|
||||||
Generate a GCP architecture diagram with Cloud Run, Cloud SQL, and
|
|
||||||
Cloud Storage for a web application
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Sequence Diagrams
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a sequence diagram showing OAuth 2.0 authorization code flow
|
|
||||||
between user, client app, auth server, and resource server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `start_session` | Opens browser with real-time diagram preview |
|
|
||||||
| `create_new_diagram` | Create a new diagram from XML |
|
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations |
|
|
||||||
| `get_diagram` | Get the current diagram XML |
|
|
||||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Ask Claude to create a diagram
|
|
||||||
2. Claude calls `start_session` to open a browser window
|
|
||||||
3. Claude generates diagram XML and sends it to the browser
|
|
||||||
4. You see the diagram update in real-time!
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
|
||||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- [Homepage](https://next-ai-drawio.jiang.jp)
|
|
||||||
- [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io)
|
|
||||||
- [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache-2.0
|
|
||||||
24
packages/mcp-server/package-lock.json
generated
24
packages/mcp-server/package-lock.json
generated
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.12",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.12",
|
"version": "0.1.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"linkedom": "^0.18.0",
|
"linkedom": "^0.18.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"next-ai-drawio-mcp": "dist/index.js"
|
"next-ai-drawio-mcp": "dist/index.js"
|
||||||
@@ -481,9 +481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.25.2",
|
"version": "1.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||||
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -520,9 +520,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.6",
|
"version": "24.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||||
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2051,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.13",
|
"version": "0.1.11",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -21,16 +21,16 @@
|
|||||||
"claude",
|
"claude",
|
||||||
"model-context-protocol"
|
"model-context-protocol"
|
||||||
],
|
],
|
||||||
"author": "DayuanJiang",
|
"author": "Biki-dev",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/DayuanJiang/next-ai-draw-io",
|
"url": "https://github.com/Biki-dev/next-ai-draw-io",
|
||||||
"directory": "packages/mcp-server"
|
"directory": "packages/mcp-server"
|
||||||
},
|
},
|
||||||
"homepage": "https://next-ai-drawio.jiang.jp",
|
"homepage": "https://next-ai-drawio.jiang.jp",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/DayuanJiang/next-ai-draw-io/issues"
|
"url": "https://github.com/Biki-dev/next-ai-draw-io/issues"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"linkedom": "^0.18.0",
|
"linkedom": "^0.18.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ COMMON STYLES:
|
|||||||
// Update session state
|
// Update session state
|
||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
currentSession.version++
|
||||||
currentSession.lastGetDiagramTime = Date.now()
|
|
||||||
|
|
||||||
// Push to embedded server state
|
// Push to embedded server state
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
|
|||||||
@@ -3,49 +3,9 @@
|
|||||||
* Copies node_modules to the standalone directory in the packaged app
|
* Copies node_modules to the standalone directory in the packaged app
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const {
|
const { cpSync, existsSync } = require("fs")
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
statSync,
|
|
||||||
} = require("fs")
|
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
|
||||||
* This is needed because cpSync with dereference:true does NOT convert symlinks.
|
|
||||||
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
|
|
||||||
*/
|
|
||||||
function copyDereferenced(src, dst) {
|
|
||||||
const lstat = lstatSync(src)
|
|
||||||
|
|
||||||
if (lstat.isSymbolicLink()) {
|
|
||||||
// Follow symlink and check what it points to
|
|
||||||
const stat = statSync(src)
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// Symlink to directory: recursively copy the directory contents
|
|
||||||
mkdirSync(dst, { recursive: true })
|
|
||||||
for (const entry of readdirSync(src)) {
|
|
||||||
copyDereferenced(path.join(src, entry), path.join(dst, entry))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Symlink to file: copy the actual file content
|
|
||||||
mkdirSync(path.join(dst, ".."), { recursive: true })
|
|
||||||
copyFileSync(src, dst)
|
|
||||||
}
|
|
||||||
} else if (lstat.isDirectory()) {
|
|
||||||
mkdirSync(dst, { recursive: true })
|
|
||||||
for (const entry of readdirSync(src)) {
|
|
||||||
copyDereferenced(path.join(src, entry), path.join(dst, entry))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mkdirSync(path.join(dst, ".."), { recursive: true })
|
|
||||||
copyFileSync(src, dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async (context) => {
|
module.exports = async (context) => {
|
||||||
const appOutDir = context.appOutDir
|
const appOutDir = context.appOutDir
|
||||||
const resourcesDir = path.join(
|
const resourcesDir = path.join(
|
||||||
@@ -65,7 +25,7 @@ module.exports = async (context) => {
|
|||||||
console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)
|
console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)
|
||||||
|
|
||||||
if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {
|
if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {
|
||||||
copyDereferenced(sourceNodeModules, targetNodeModules)
|
cpSync(sourceNodeModules, targetNodeModules, { recursive: true })
|
||||||
console.log("[afterPack] node_modules copied successfully")
|
console.log("[afterPack] node_modules copied successfully")
|
||||||
} else {
|
} else {
|
||||||
console.error("[afterPack] Source or target directory not found!")
|
console.error("[afterPack] Source or target directory not found!")
|
||||||
|
|||||||
@@ -6,54 +6,13 @@
|
|||||||
* that electron-builder can properly include
|
* that electron-builder can properly include
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
} from "node:fs"
|
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
const rootDir = join(__dirname, "..")
|
const rootDir = join(__dirname, "..")
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
|
||||||
* This is needed because cpSync with dereference:true does NOT convert symlinks.
|
|
||||||
* macOS codesign fails if bundle contains symlinks pointing outside the bundle.
|
|
||||||
*/
|
|
||||||
function copyDereferenced(src, dst) {
|
|
||||||
const lstat = lstatSync(src)
|
|
||||||
|
|
||||||
if (lstat.isSymbolicLink()) {
|
|
||||||
// Follow symlink and check what it points to
|
|
||||||
const stat = statSync(src)
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// Symlink to directory: recursively copy the directory contents
|
|
||||||
mkdirSync(dst, { recursive: true })
|
|
||||||
for (const entry of readdirSync(src)) {
|
|
||||||
copyDereferenced(join(src, entry), join(dst, entry))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Symlink to file: copy the actual file content
|
|
||||||
mkdirSync(join(dst, ".."), { recursive: true })
|
|
||||||
copyFileSync(src, dst)
|
|
||||||
}
|
|
||||||
} else if (lstat.isDirectory()) {
|
|
||||||
mkdirSync(dst, { recursive: true })
|
|
||||||
for (const entry of readdirSync(src)) {
|
|
||||||
copyDereferenced(join(src, entry), join(dst, entry))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mkdirSync(join(dst, ".."), { recursive: true })
|
|
||||||
copyFileSync(src, dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const standaloneDir = join(rootDir, ".next", "standalone")
|
const standaloneDir = join(rootDir, ".next", "standalone")
|
||||||
const staticDir = join(rootDir, ".next", "static")
|
const staticDir = join(rootDir, ".next", "static")
|
||||||
const targetDir = join(rootDir, "electron-standalone")
|
const targetDir = join(rootDir, "electron-standalone")
|
||||||
@@ -71,19 +30,12 @@ mkdirSync(targetDir, { recursive: true })
|
|||||||
|
|
||||||
// Copy standalone (includes node_modules)
|
// Copy standalone (includes node_modules)
|
||||||
console.log("Copying standalone directory...")
|
console.log("Copying standalone directory...")
|
||||||
copyDereferenced(standaloneDir, targetDir)
|
cpSync(standaloneDir, targetDir, { recursive: true })
|
||||||
|
|
||||||
// Copy static files
|
// Copy static files
|
||||||
console.log("Copying static files...")
|
console.log("Copying static files...")
|
||||||
const targetStaticDir = join(targetDir, ".next", "static")
|
const targetStaticDir = join(targetDir, ".next", "static")
|
||||||
copyDereferenced(staticDir, targetStaticDir)
|
mkdirSync(targetStaticDir, { recursive: true })
|
||||||
|
cpSync(staticDir, targetStaticDir, { recursive: true })
|
||||||
// Copy public folder (required for favicon-white.svg and other assets)
|
|
||||||
console.log("Copying public folder...")
|
|
||||||
const publicDir = join(rootDir, "public")
|
|
||||||
const targetPublicDir = join(targetDir, "public")
|
|
||||||
if (existsSync(publicDir)) {
|
|
||||||
copyDereferenced(publicDir, targetPublicDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Done! Files prepared in electron-standalone/")
|
console.log("Done! Files prepared in electron-standalone/")
|
||||||
|
|||||||
@@ -1,141 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import {
|
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
|
||||||
resolveBaseURL,
|
|
||||||
supportsImageInput,
|
|
||||||
supportsPromptCaching,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
|
|
||||||
describe("resolveBaseURL", () => {
|
|
||||||
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
|
||||||
const USER_BASE_URL = "https://user-proxy.example.com"
|
|
||||||
const DEFAULT_BASE_URL = "https://api.provider.com/v1"
|
|
||||||
const USER_API_KEY = "user-api-key-123"
|
|
||||||
|
|
||||||
describe("when user provides their own API key", () => {
|
|
||||||
it("uses user's baseUrl when provided", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
USER_BASE_URL,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(USER_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses default baseUrl when user provides no baseUrl", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns undefined when user provides no baseUrl and no default exists", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT use server's baseUrl even when available", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
undefined,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
// Should NOT return SERVER_BASE_URL
|
|
||||||
expect(result).not.toBe(SERVER_BASE_URL)
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("prefers user's baseUrl over default", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
USER_BASE_URL,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(USER_BASE_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("when using server credentials (no user API key)", () => {
|
|
||||||
it("uses user's baseUrl when provided (overrides server)", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
null,
|
|
||||||
USER_BASE_URL,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(USER_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to server's baseUrl when no user baseUrl", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to default when no user or server baseUrl", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
undefined,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns undefined when no baseUrl available anywhere", () => {
|
|
||||||
const result = resolveBaseURL(null, null, undefined, undefined)
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles undefined apiKey same as null", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
undefined,
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("handles empty string apiKey as falsy (uses server config)", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
"",
|
|
||||||
null,
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
// Empty string is falsy, so should use server config
|
|
||||||
expect(result).toBe(SERVER_BASE_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles empty string baseUrl as falsy", () => {
|
|
||||||
const result = resolveBaseURL(
|
|
||||||
USER_API_KEY,
|
|
||||||
"",
|
|
||||||
SERVER_BASE_URL,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
)
|
|
||||||
// Empty string baseUrl is falsy, should fall back to default
|
|
||||||
expect(result).toBe(DEFAULT_BASE_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("supportsPromptCaching", () => {
|
describe("supportsPromptCaching", () => {
|
||||||
it("returns true for Claude models", () => {
|
it("returns true for Claude models", () => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
loadFlattenedServerModels,
|
|
||||||
type ServerModelsConfig,
|
|
||||||
ServerModelsConfigSchema,
|
|
||||||
} from "@/lib/server-model-config"
|
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env }
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
|
|
||||||
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
|
|
||||||
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("ServerModelsConfigSchema", () => {
|
|
||||||
it("accepts valid provider names", () => {
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects invalid provider names", () => {
|
|
||||||
const invalidConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Invalid Provider",
|
|
||||||
// Cast to any so we can verify runtime validation, not TypeScript
|
|
||||||
provider: "invalid-provider" as any,
|
|
||||||
models: ["model-1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
ServerModelsConfigSchema.parse(invalidConfig as any),
|
|
||||||
).toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("loadFlattenedServerModels", () => {
|
|
||||||
it("returns empty array when config file is missing", async () => {
|
|
||||||
// Point to a non-existent config path so fs.readFile throws ENOENT
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
expect(models).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("flattens providers and marks default model from env var config", async () => {
|
|
||||||
// Use AI_MODELS_CONFIG env var instead of file
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
|
|
||||||
expect(models.length).toBe(2)
|
|
||||||
|
|
||||||
const defaults = models.filter((m) => m.isDefault)
|
|
||||||
expect(defaults.length).toBe(1)
|
|
||||||
|
|
||||||
const defaultModel = defaults[0]
|
|
||||||
expect(defaultModel.provider).toBe("openai")
|
|
||||||
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user