Compare commits

..

12 Commits

Author SHA1 Message Date
dayuan.jiang
011b883cdb feat(i18n): add validation strings for ValidationCard component
- Add validation section to en.json, zh.json, ja.json dictionaries
- Update ValidationCard to use useDictionary hook
- Replace all hardcoded English strings with i18n keys
2026-01-20 20:43:35 +09:00
dayuan.jiang
fd9302e736 fix: return empty string for valid result with no issues in formatValidationFeedback 2026-01-20 20:04:29 +09:00
dayuan.jiang
fa06c61538 fix: resolve TypeScript errors in electron-standalone
- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking
2026-01-20 19:49:12 +09:00
dayuan.jiang
60994d281e fix: improve VLM validation with bug fixes and i18n
- Fix race condition in pendingValidationRef (reject previous pending validation)
- Fix response format consistency (use streaming for all responses)
- Remove dead code (unused lastRequestRef and ValidationRequest interface)
- Consolidate duplicate types (re-export from validation-schema.ts)
- Add 'success_with_warnings' status for valid diagrams with warnings
- Fix tool card auto-collapse (only collapse once, respect user toggle)
- Set VLM validation default to disabled
- Add i18n support for diagram validation settings (en/zh/ja)
- Mark feature as experimental in settings UI
2026-01-20 19:45:26 +09:00
Jinze Yu
6640272f90 fix(validation): add aria-hidden to icons to prevent duplicate ID warning 2026-01-19 18:21:56 +09:00
Jinze Yu
86560e0fc0 fix(validation): use 'Valid' instead of 'Complete' for validation success
Change ValidationCard success label from 'Complete' to 'Valid' to avoid
conflicting with ToolCallCard's 'Complete' badge in E2E tests. This fixes
the diagram-generation E2E test that expects a specific count of 'Complete'
badges.
2026-01-19 10:41:58 +09:00
Jinze Yu
bb6c7c11ce Merge origin/main into features/validate-diagram-with-vlm 2026-01-19 01:35:34 +09:00
Jinze Yu
6b0296a97f fix(validation): extract schema to shared file for client/server compatibility
Move ValidationResultSchema to lib/validation-schema.ts to avoid importing
server-side modules (ai-providers) into client-side code. This fixes the
Turbopack build error caused by the hook importing from the API route.
2026-01-19 01:30:51 +09:00
Jinze Yu
85513b8c00 refactor(validation): use AI SDK experimental_useObject hook instead of raw fetch
- Change API endpoint from generateObject to streamObject for useObject compatibility
- Create useValidateDiagram hook using AI SDK's experimental_useObject for reactive validation
- Update useDiagramToolHandlers to accept validation function as parameter
- Update chat-panel to use new useValidateDiagram hook
- Remove validateRenderedDiagram function from lib/diagram-validator.ts (now in hook)
- Export ValidationResultSchema from API route for client-side use
2026-01-19 01:10:31 +09:00
Jinze Yu
24270e2622 refactor(validation): use AI SDK structured outputs and address review feedback
- Replace generateText + manual JSON parsing with generateObject and Zod schema
  for type-safe structured validation output
- Use AbortSignal.timeout() instead of Promise.race for cleaner timeout handling
- Add timeout validation with minimum 1000ms to handle malformed env values
- Remove unused xml parameter from validateRenderedDiagram API
- Remove parseValidationResponse function (now handled by schema)
- Clear validationStates on session switch and new chat to prevent memory leak
- Update 100ms render delay comment to clarify best-effort heuristic
- Remove unused useEffect import from ValidationCard
- Fix optional chaining lint warning in ValidationCard
- Add unit tests for formatValidationFeedback function
2026-01-19 00:47:49 +09:00
Jinze Yu
07c9e7d758 Merge remote-tracking branch 'origin/main' into features/validate-diagram-with-vlm 2026-01-17 20:59:51 +09:00
Jinze Yu
ee4c0149f1 [Feature] Add VLM-based diagram validation
Add automatic VLM (Vision Language Model) validation after display_diagram
tool execution. The system captures a screenshot of the rendered diagram,
sends it to a VLM for visual analysis, and uses feedback to improve
diagram quality through the existing retry mechanism.

Changes:
- Add /api/validate-diagram endpoint for VLM validation
- Add diagram-validator.ts for client-side validation orchestration
- Add validation-prompts.ts for VLM system prompts
- Add ValidationCard component to display validation status in chat
- Add PNG capture functionality to diagram context
- Integrate validation into tool handlers with retry support (max 3)
- Add "Improve with Suggestions" button for manual regeneration
- Add settings toggle to enable/disable VLM validation
- Add getValidationModel() helper in ai-providers.ts
2026-01-17 17:16:12 +09:00
76 changed files with 10216 additions and 8901 deletions

View File

@@ -47,10 +47,6 @@ To run tests with UI mode:
npx playwright test --ui npx playwright test --ui
``` ```
## Before You Start
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
## Pull Requests ## Pull Requests
1. Create a feature branch 1. Create a feature branch
@@ -61,12 +57,6 @@ For **significant changes** (new features, architecture changes, large refactors
CI will run the full test suite on your PR. CI will run the full test suite on your PR.
## Code Review
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
- **Valid suggestions**: Please address them in your code.
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
## Issues ## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used. Include steps to reproduce, expected vs actual behavior, and AI provider used.

View File

@@ -40,3 +40,5 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev

View File

@@ -34,15 +34,6 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -66,16 +57,6 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -99,7 +80,7 @@ jobs:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }} api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5' organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io' project-slug: 'next-ai-draw-io'
signing-policy-slug: 'release-signing' signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'windows-exe' artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true wait-for-completion: true

5
.gitignore vendored
View File

@@ -56,8 +56,6 @@ push-via-ec2.sh
/dist-electron/ /dist-electron/
/release/ /release/
/electron-standalone/ /electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg *.dmg
*.exe *.exe
*.AppImage *.AppImage
@@ -72,6 +70,3 @@ CLAUDE.md
.edgeone .edgeone
opencode.json opencode.json
ai-models.json ai-models.json
# local backups
*.bak

View File

@@ -9,7 +9,6 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# Install dependencies # Install dependencies
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN npm install RUN npm install
# Stage 2: Build application # Stage 2: Build application
@@ -35,11 +34,6 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
ARG NEXT_PUBLIC_BASE_PATH="" ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Control sponsorship and self-hosting messaging in quota notifications.
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
ARG NEXT_PUBLIC_SELFHOSTED=""
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
# Build Next.js application (standalone mode) # Build Next.js application (standalone mode)
RUN npm run build RUN npm run build

View File

@@ -19,7 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization. A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model! > Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -202,7 +202,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
## Multi-Provider Support ## Multi-Provider Support
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) - [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock (default) - AWS Bedrock (default)
- OpenAI - OpenAI
- Anthropic - Anthropic
@@ -244,7 +244,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Support & Contact ## Support & Contact
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models! **Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site! If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { FaGithub } from "react-icons/fa" import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "关于 - Next AI Draw.io", title: "关于 - Next AI Draw.io",
@@ -78,7 +78,7 @@ export default function AboutCN() {
<p> <p>
{" "} {" "}
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline" className="font-semibold text-blue-600 hover:underline"
@@ -87,7 +87,7 @@ export default function AboutCN() {
</a> </a>
{" "} {" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
glm-4.7 K2-thinking
</span>{" "} </span>{" "}
{" "} {" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
@@ -97,23 +97,6 @@ export default function AboutCN() {
</p> </p>
</div> </div>
{/* Invite Poster */}
<div className="text-center mb-5">
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/volcengine-invite.png"
alt="火山引擎方舟 Coding Plan"
width={300}
height={400}
className="mx-auto rounded-lg"
/>
</a>
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -294,7 +277,7 @@ export default function AboutCN() {
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
@@ -329,7 +312,7 @@ export default function AboutCN() {
<p className="text-gray-700 mb-4 font-semibold"> <p className="text-gray-700 mb-4 font-semibold">
{" "} {" "}
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { FaGithub } from "react-icons/fa" import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "概要 - Next AI Draw.io", title: "概要 - Next AI Draw.io",
@@ -86,7 +86,7 @@ export default function AboutJA() {
<p> <p>
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline" className="font-semibold text-blue-600 hover:underline"
@@ -95,7 +95,7 @@ export default function AboutJA() {
</a> </a>
{" "} {" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
glm-4.7 K2-thinking
</span>{" "} </span>{" "}
使{" "} 使{" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
@@ -292,7 +292,7 @@ export default function AboutJA() {
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
@@ -327,7 +327,7 @@ export default function AboutJA() {
<p className="text-gray-700 mb-4 font-semibold"> <p className="text-gray-700 mb-4 font-semibold">
APIトークン使用を支援してくださった{" "} APIトークン使用を支援してくださった{" "}
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { FaGithub } from "react-icons/fa" import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "About - Next AI Draw.io", title: "About - Next AI Draw.io",
@@ -87,7 +87,7 @@ export default function About() {
Great news! Thanks to the generous Great news! Thanks to the generous
sponsorship from{" "} sponsorship from{" "}
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline" className="font-semibold text-blue-600 hover:underline"
@@ -96,7 +96,7 @@ export default function About() {
</a> </a>
, the demo site now uses the powerful{" "} , the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
glm-4.7 K2-thinking
</span>{" "} </span>{" "}
model for better diagram generation! Sign up model for better diagram generation! Sign up
via the link to get{" "} via the link to get{" "}
@@ -311,7 +311,7 @@ export default function About() {
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
@@ -348,7 +348,7 @@ export default function About() {
<p className="text-gray-700 mb-4 font-semibold"> <p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "} Special thanks to{" "}
<a <a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"

View File

@@ -41,24 +41,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }> params: Promise<{ lang: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { lang: rawLang } = await params const { lang: rawLang } = await params
const lang = ( const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata // Default to English metadata
const titles: Record<Locale, string> = { const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator", en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator", zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator", ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
} }
const descriptions: Record<Locale, string> = { const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.", zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.", ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
} }
return { return {
@@ -85,14 +80,7 @@ export async function generateMetadata({
type: "website", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
locale: locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [ images: [
{ {
url: "/architecture.png", url: "/architecture.png",
@@ -127,7 +115,6 @@ export async function generateMetadata({
en: "/en", en: "/en",
zh: "/zh", zh: "/zh",
ja: "/ja", ja: "/ja",
"zh-Hant": "/zh-Hant",
}, },
}, },
} }

View File

@@ -11,16 +11,13 @@ import {
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
drawioRef, useDiagram()
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -31,12 +28,6 @@ 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 [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false) const isMobileRef = useRef(false)
@@ -73,22 +64,6 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark) document.documentElement.classList.toggle("dark", prefersDark)
} }
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -97,17 +72,6 @@ export default function Home() {
onDrawioLoad() onDrawioLoad()
}, [onDrawioLoad]) }, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
// Only suppress modified state when persistence is available
if (canPersist) {
drawioRef.current?.status({ message: "", modified: false })
}
},
[canPersist, drawioRef, handleDiagramAutoSave],
)
const handleDarkModeChange = () => { const handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
@@ -191,41 +155,25 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && canPersistChecked && ( {isLoaded && (
<div <div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`} key={`${drawioUi}-${darkMode}-${currentLang}`}
ref={drawioRef} ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: false,
libraries: false, libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false, saveAndExit: false,
noSaveBtn: true, noSaveBtn: true,
noExitBtn: true, noExitBtn: true,
dark: darkMode, dark: darkMode,
lang: currentLang, lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}} }}
/> />
</div> </div>

View File

@@ -14,7 +14,6 @@ import path from "path"
import { z } from "zod" import { z } from "zod"
import { import {
getAIModel, getAIModel,
SINGLE_SYSTEM_PROVIDERS,
supportsImageInput, supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
@@ -90,12 +89,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
} }
} }
const body = await req.json() const { messages, xml, previousXml, sessionId } = await req.json()
const { messages, xml, previousXml, sessionId } = body
const customSystemMessage =
typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000)
: ""
// Get user ID for Langfuse tracking and quota // Get user ID for Langfuse tracking and quota
const userId = getUserIdFromRequest(req) const userId = getUserIdFromRequest(req)
@@ -124,10 +118,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === SERVER-SIDE QUOTA CHECK START === // === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set // Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!( const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") && req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
(req.headers.get("x-ai-api-key") ||
req.headers.get("x-aws-access-key-id") ||
req.headers.get("x-vertex-api-key"))
) )
// Skip quota check if: quota disabled, user has own API key, or is anonymous // Skip quota check if: quota disabled, user has own API key, or is anonymous
@@ -192,7 +183,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Check if this is a server model with custom env var names // Check if this is a server model with custom env var names
let serverModelConfig: { let serverModelConfig: {
apiKeyEnv?: string | string[] apiKeyEnv?: string
baseUrlEnv?: string baseUrlEnv?: string
provider?: string provider?: string
} = {} } = {}
@@ -241,13 +232,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
) )
// Get AI model with optional client overrides // Get AI model with optional client overrides
const { const { model, providerOptions, headers, modelId } =
model, getAIModel(clientOverrides)
providerOptions,
headers,
modelId,
provider: resolvedProvider,
} = getAIModel(clientOverrides)
// Check if model supports prompt caching // Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId) const shouldCache = supportsPromptCaching(modelId)
@@ -257,9 +243,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle) const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage
// Extract file parts (images) from the last user message // Extract file parts (images) from the last user message
const fileParts = const fileParts =
@@ -432,63 +415,37 @@ ${userInputText}
} }
// System messages with multiple cache breakpoints for optimal caching: // System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message // - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn // - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// Some providers (e.g. MiniMax) don't support multiple system messages // This allows: if only user message changes, both system caches are reused
// Merge them into a single system message for compatibility // if XML changes, instruction cache is still reused
const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change)
const xmlContext = `${ {
previousXml role: "system" as const,
? `Previous diagram XML (before user's last message): content: systemMessage,
"""xml ...(shouldCache && {
${previousXml} providerOptions: {
""" bedrock: { cachePoint: { type: "default" } },
},
` }),
: "" },
}Current diagram XML (AUTHORITATIVE - the source of truth): // Cache breakpoint 2: Previous and Current diagram XML context
"""xml {
${xml || ""} role: "system" as const,
""" content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
...(shouldCache && {
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!` providerOptions: {
bedrock: { cachePoint: { type: "default" } },
const systemMessages = isSingleSystemProvider },
? [ }),
{ },
role: "system" as const, ]
content: `${finalSystemMessage}\n\n${xmlContext}`,
},
]
: [
// Cache breakpoint 1: Instructions (+ optional custom instructions)
{
role: "system" as const,
content: finalSystemMessage,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: xmlContext,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
]
const allMessages = [...systemMessages, ...enhancedMessages] const allMessages = [...systemMessages, ...enhancedMessages]
const result = streamText({ const result = streamText({
model, model,
abortSignal: req.signal,
...(process.env.MAX_OUTPUT_TOKENS && { ...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10), maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}), }),
@@ -702,7 +659,7 @@ Available libraries:
- Networking: cisco19, network, kubernetes, vvd, rack - Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping - Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap - General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android, material_design - UI/Mockups: android
- Enterprise: citrix, sap, mscae, atlassian - Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan - Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons - Icons: webicons
@@ -747,7 +704,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
if ( if (
(error as NodeJS.ErrnoException).code === "ENOENT" (error as NodeJS.ErrnoException).code === "ENOENT"
) { ) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack` return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
} }
console.error( console.error(
`[get_shape_library] Error loading "${library}":`, `[get_shape_library] Error loading "${library}":`,

View File

@@ -5,7 +5,6 @@ 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
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@@ -35,31 +34,6 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
try {
const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT },
signal: headController.signal,
})
const contentType = headResponse.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
},
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
// Extract article content with timeout to avoid tying up server resources // Extract article content with timeout to avoid tying up server resources
const controller = new AbortController() const controller = new AbortController()
@@ -70,7 +44,9 @@ export async function POST(req: Request) {
let article let article
try { try {
article = await extract(url, undefined, { article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT }, headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal, signal: controller.signal,
}) })
} catch (err: any) { } catch (err: any) {

View File

@@ -9,9 +9,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai" import { generateText } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2" import { createOllama } from "ollama-ai-provider-v2"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs" export const runtime = "nodejs"
@@ -176,21 +174,10 @@ export async function POST(req: Request) {
} }
case "ollama": { case "ollama": {
// SECURITY: Mirror ai-providers.ts guard — only use server const ollama = createOllama({
// OLLAMA_API_KEY when the URL is also from server config. baseURL: baseUrl || "http://localhost:11434",
const ollamaApiKey = baseUrl
? apiKey || undefined
: apiKey || process.env.OLLAMA_API_KEY || undefined
const ollamaProvider = createOllama({
baseURL:
baseUrl ||
process.env.OLLAMA_BASE_URL ||
"https://ollama.com/api",
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
}) })
model = ollamaProvider(modelId) model = ollama(modelId)
break break
} }
@@ -321,59 +308,6 @@ export async function POST(req: Request) {
} }
} }
case "minimax": {
const rawUrl =
baseUrl ||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
"https://api.minimaxi.com/anthropic"
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawUrl)
if (isAnthropicCompatible) {
const minimax = createAnthropic({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
}
break
}
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
const baseURL =
baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
""
if (!baseURL) {
return NextResponse.json(
{
valid: false,
error: `No base URL configured for provider: ${provider}`,
},
{ status: 400 },
)
}
const openai = createOpenAI({
apiKey,
baseURL,
})
model = openai.chat(modelId)
break
}
default: default:
return NextResponse.json( return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` }, { valid: false, error: `Unknown provider: ${provider}` },

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -1,6 +1,5 @@
import { Cloud } from "lucide-react" import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react" import type { ComponentProps, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import { import {
Command, Command,
CommandDialog, CommandDialog,
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({ export const ModelSelectorList = ({
className, className,
...props ...props
}: ModelSelectorListProps) => { }: ModelSelectorListProps) => (
const listRef = useRef<ElementRef<typeof CommandList>>(null) <div className="relative">
const [showShadow, setShowShadow] = useState(false) <CommandList
className={cn(
useEffect(() => { // Hide scrollbar on all platforms
const listElement = listRef.current "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
if (!listElement) return className,
)}
const checkScroll = () => { {...props}
const { scrollTop, scrollHeight, clientHeight } = listElement />
// Show shadow if there is more content below {/* Bottom shadow indicator for scrollable content */}
// Using a small threshold to handle fractional pixel rendering <div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
setShowShadow( </div>
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1, )
)
}
// Initial check
checkScroll()
// Event listeners
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Observe content changes (e.g. async loading of items)
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty> export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -5,8 +5,8 @@ import {
History, History,
Image as ImageIcon, Image as ImageIcon,
Link, Link,
Loader2,
Send, Send,
Square,
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { import {
@@ -154,7 +154,6 @@ interface ChatInputProps {
status: "submitted" | "streaming" | "ready" | "error" status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[] files?: File[]
onFileChange?: (files: File[]) => void onFileChange?: (files: File[]) => void
pdfData?: Map< pdfData?: Map<
@@ -170,8 +169,8 @@ interface ChatInputProps {
models?: FlattenedModel[] models?: FlattenedModel[]
selectedModelId?: string selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean showUnvalidatedModels?: boolean
onConfigureModels?: () => void
// Focus control props // Focus control props
shouldFocus?: boolean shouldFocus?: boolean
onFocused?: () => void onFocused?: () => void
@@ -184,7 +183,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
status, status,
onSubmit, onSubmit,
onChange, onChange,
onStop,
files = [], files = [],
onFileChange = () => {}, onFileChange = () => {},
pdfData = new Map(), pdfData = new Map(),
@@ -195,8 +193,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
models = [], models = [],
selectedModelId, selectedModelId,
onModelSelect = () => {}, onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false, showUnvalidatedModels = false,
onConfigureModels = () => {},
shouldFocus = false, shouldFocus = false,
onFocused, onFocused,
}, },
@@ -557,30 +555,24 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
showUnvalidatedModels={showUnvalidatedModels} showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />
{(status === "streaming" || status === "submitted") && <Button
onStop ? ( type="submit"
<Button disabled={isDisabled || !input.trim()}
type="button" size="sm"
onClick={onStop} className="h-8 px-4 rounded-xl font-medium shadow-sm"
size="sm" aria-label={
variant="destructive" isDisabled ? dict.chat.sending : dict.chat.send
className="h-8 w-8 p-0 rounded-xl shadow-sm" }
aria-label={dict.chat.stopGeneration} >
> {isDisabled ? (
<Square className="h-4 w-4" /> <Loader2 className="h-4 w-4 animate-spin" />
</Button> ) : (
) : ( <>
<Button <Send className="h-4 w-4 mr-1.5" />
type="submit" {dict.chat.send}
disabled={isDisabled || !input.trim()} </>
size="sm" )}
className="h-8 px-4 rounded-xl font-medium shadow-sm" </Button>
aria-label={dict.chat.send}
>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</Button>
)}
</div> </div>
</div> </div>
<HistoryDialog <HistoryDialog

View File

@@ -9,13 +9,13 @@ import {
Copy, Copy,
FileCode, FileCode,
FileText, FileText,
Link,
Pencil, Pencil,
RotateCcw, RotateCcw,
ThumbsDown, ThumbsDown,
ThumbsUp, ThumbsUp,
X, X,
} from "lucide-react" } from "lucide-react"
import Image from "next/image"
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
@@ -30,7 +30,6 @@ import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard" import type { ValidationState } from "@/components/chat/ValidationCard"
import { ValidationCard } from "@/components/chat/ValidationCard" import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -60,20 +59,20 @@ function getCompleteOperations(
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs) // Helper to split text content into regular text and file sections (PDF or text files)
interface TextSection { interface TextSection {
type: "text" | "file" | "url" type: "text" | "file"
content: string content: string
filename?: string filename?: string
charCount?: number charCount?: number
fileType?: "pdf" | "text" | "url" fileType?: "pdf" | "text"
} }
function splitTextIntoFileSections(text: string): TextSection[] { function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = [] const sections: TextSection[] = []
// Match [PDF: filename], [File: filename], or [URL: url] patterns // Match [PDF: filename] or [File: filename] patterns
const filePattern = const filePattern =
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g /\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
let lastIndex = 0 let lastIndex = 0
let match let match
@@ -84,34 +83,28 @@ function splitTextIntoFileSections(text: string): TextSection[] {
sections.push({ type: "text", content: beforeText }) sections.push({ type: "text", content: beforeText })
} }
// Add file/url section // Add file section
const sectionType = match[1].toLowerCase() const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const filename = match[2].trim() const filename = match[2].trim()
const content = match[3].trim() const fileContent = match[3].trim()
sections.push({ sections.push({
type: sectionType === "url" ? "url" : "file", type: "file",
content: content, content: fileContent,
filename, filename,
charCount: content.length, charCount: fileContent.length,
fileType, fileType,
}) })
lastIndex = match.index + match[0].length lastIndex = match.index + match[0].length
} }
// Add remaining text after last section // Add remaining text after last file section
const remainingText = text.slice(lastIndex).trim() const remainingText = text.slice(lastIndex).trim()
if (remainingText) { if (remainingText) {
sections.push({ type: "text", content: remainingText }) sections.push({ type: "text", content: remainingText })
} }
// If no file/url sections found, return original text // If no file sections found, return original text
if (sections.length === 0) { if (sections.length === 0) {
sections.push({ type: "text", content: text }) sections.push({ type: "text", content: text })
} }
@@ -130,8 +123,8 @@ const getMessageTextContent = (message: UIMessage): string => {
// Get only the user's original text, excluding appended file content // Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => { const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message) const fullText = getMessageTextContent(message)
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended // Strip out [PDF: ...] and [File: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/ const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim() return fullText.replace(filePattern, "").trim()
} }
@@ -1091,14 +1084,12 @@ export function ChatMessageDisplay({
) => { ) => {
if ( if (
section.type === section.type ===
"file" || "file"
section.type ===
"url"
) { ) {
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}` const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
const isExpanded = const isExpanded =
expandedPdfSections[ expandedPdfSections[
sectionKey pdfKey
] ?? ] ??
false false
const charDisplay = const charDisplay =
@@ -1107,27 +1098,10 @@ export function ChatMessageDisplay({
1000 1000
? `${(section.charCount / 1000).toFixed(1)}k` ? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount : section.charCount
// Icon selector
const Icon =
section.fileType ===
"pdf"
? FileText
: section.fileType ===
"url"
? Link
: FileCode
const iconColor =
section.fileType ===
"pdf"
? "text-red-500"
: "text-blue-700"
return ( return (
<div <div
key={ key={
sectionKey pdfKey
} }
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden" className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
> >
@@ -1142,7 +1116,7 @@ export function ChatMessageDisplay({
prev, prev,
) => ({ ) => ({
...prev, ...prev,
[sectionKey]: [pdfKey]:
!isExpanded, !isExpanded,
}), }),
) )
@@ -1150,10 +1124,13 @@ export function ChatMessageDisplay({
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors" className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon {section.fileType ===
className={`h-4 w-4 ${iconColor}`} "pdf" ? (
/> <FileText className="h-4 w-4 text-red-500" />
<span className="text-xs font-medium truncate max-w-[200px]"> ) : (
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
{ {
section.filename section.filename
} }

View File

@@ -8,7 +8,8 @@ import {
PanelRightOpen, PanelRightOpen,
Settings, Settings,
} from "lucide-react" } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import type React from "react" import type React from "react"
import { import {
useCallback, useCallback,
@@ -21,7 +22,6 @@ import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input" import { ChatInput } from "@/components/chat-input"
import Image from "@/components/image-with-basepath"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
@@ -131,29 +131,32 @@ export default function ChatPanel({
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session") const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
resolverRef.current = resolve if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
if (saveToHistory) { if (saveToHistory) {
onExport() onExport()
} else { } else {
handleExportWithoutHistory() handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => { new Promise<string>((_, reject) =>
const currentResolver = resolverRef.current setTimeout(
setTimeout(() => { () =>
if (resolverRef.current === currentResolver) { reject(
resolverRef.current = null new Error(
} "Chart export timed out after 10 seconds",
reject(new Error("Chart export timed out after 10 seconds")) ),
}, 10000) ),
}), 10000,
),
),
]) ])
} }
@@ -176,7 +179,6 @@ export default function ChatPanel({
const [tpmLimit, setTpmLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0)
const [minimalStyle, setMinimalStyle] = useState(false) const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false) const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("")
const [shouldFocusInput, setShouldFocusInput] = 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)
@@ -195,14 +197,6 @@ export default function ChatPanel({
} }
}, []) }, [])
// Load custom system message from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)
if (stored !== null) {
setCustomSystemMessage(stored)
}
}, [])
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
fetch(getApiEndpoint("/api/config")) fetch(getApiEndpoint("/api/config"))
@@ -313,12 +307,6 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value)) localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
}, []) }, [])
// Handler for custom system message change
const handleCustomSystemMessageChange = useCallback((value: string) => {
setCustomSystemMessage(value)
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, [])
// Ref to store the sendMessage function for use in callbacks // Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null) const sendMessageRef = useRef<typeof sendMessage | null>(null)
@@ -351,155 +339,151 @@ export default function ChatPanel({
onValidationStateChange: handleValidationStateChange, onValidationStateChange: handleValidationStateChange,
}) })
const { const { messages, sendMessage, addToolOutput, status, error, setMessages } =
messages, useChat({
sendMessage, transport: new DefaultChatTransport({
addToolOutput, api: getApiEndpoint("/api/chat"),
status, }),
error, onToolCall: async ({ toolCall }) => {
setMessages, await handleToolCall({ toolCall }, addToolOutput)
stop, },
} = useChat({ onError: (error) => {
transport: new DefaultChatTransport({ // Handle server-side quota limit (429 response)
api: getApiEndpoint("/api/chat"), // AI SDK puts the full response body in error.message for non-OK responses
}), try {
onToolCall: async ({ toolCall }) => { const data = JSON.parse(error.message)
await handleToolCall({ toolCall }, addToolOutput) if (data.type === "request") {
}, quotaManager.showQuotaLimitToast(data.used, data.limit)
onError: (error) => { return
// Handle server-side quota limit (429 response) }
// AI SDK puts the full response body in error.message for non-OK responses if (data.type === "token") {
try { quotaManager.showTokenLimitToast(data.used, data.limit)
const data = JSON.parse(error.message) return
if (data.type === "request") { }
quotaManager.showQuotaLimitToast(data.used, data.limit) if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return return
} }
if (data.type === "token") { if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast(data.used, data.limit) quotaManager.showTokenLimitToast()
return return
} }
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: () => {},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if ( if (
continuationRetryCountRef.current >= error.message.includes("Rate limit exceeded") ||
MAX_CONTINUATION_RETRY_COUNT error.message.includes("tokens per minute")
) { ) {
toast.error( quotaManager.showTPMLimitToast()
formatMessage(dict.errors.continuationRetryLimit, { return
max: MAX_CONTINUATION_RETRY_COUNT, }
}),
) // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage =
"Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [
{ type: "text" as const, text: friendlyMessage },
],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: () => {},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
return false return false
} }
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
formatMessage(dict.errors.retryLimit, {
max: MAX_AUTO_RETRY_COUNT,
}),
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true // Continuation mode: limited retries for truncation handling
}, if (isInContinuationMode) {
}) if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
formatMessage(dict.errors.continuationRetryLimit, {
max: MAX_CONTINUATION_RETRY_COUNT,
}),
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
formatMessage(dict.errors.retryLimit, {
max: MAX_AUTO_RETRY_COUNT,
}),
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true
},
})
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions) // Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => { useEffect(() => {
@@ -601,7 +585,7 @@ export default function ChatPanel({
try { try {
const currentSession = sessionManager.currentSession const currentSession = sessionManager.currentSession
if (currentSession) { if (currentSession && currentSession.messages.length > 0) {
// Restore from session manager (IndexedDB) // Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(currentSession) syncUIWithSession(currentSession)
@@ -638,7 +622,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session // Sync UI with new session
if (newSession) { if (newSession && newSession.messages.length > 0) {
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(newSession) syncUIWithSession(newSession)
} else if (!newSession) { } else if (!newSession) {
@@ -693,7 +677,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(async () => {
try { try {
if (messages.length > 0 || hasDiagramNow) { if (messages.length > 0) {
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession, withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -715,7 +699,6 @@ export default function ChatPanel({
} }
} }
}, [ }, [
chartXML,
messages, messages,
status, status,
sessionIsAvailable, sessionIsAvailable,
@@ -746,8 +729,7 @@ export default function ChatPanel({
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if ( if (
document.visibilityState === "hidden" && document.visibilityState === "hidden" &&
(messagesRef.current.length > 0 || messagesRef.current.length > 0
isRealDiagram(chartXMLRef.current))
) { ) {
try { try {
// Attempt to save session - browser may not wait for completion // Attempt to save session - browser may not wait for completion
@@ -919,10 +901,10 @@ export default function ChatPanel({
if (result.wasCurrentSession) { if (result.wasCurrentSession) {
// Deleted current session - clear UI and URL // Deleted current session - clear UI and URL
syncUIWithSession(null) syncUIWithSession(null)
router.replace(pathname, { scroll: false }) router.replace(window.location.pathname, { scroll: false })
} }
}, },
[sessionManager, syncUIWithSession, router, pathname], [sessionManager, syncUIWithSession, router],
) )
const handleNewChat = useCallback(async () => { const handleNewChat = useCallback(async () => {
@@ -955,7 +937,7 @@ export default function ChatPanel({
toast.success(dict.dialogs.clearSuccess) toast.success(dict.dialogs.clearSuccess)
// Clear URL param to show blank state // Clear URL param to show blank state
router.replace(pathname, { scroll: false }) router.replace(window.location.pathname, { scroll: false })
// After starting a fresh chat, move focus back to the chat input // After starting a fresh chat, move focus back to the chat input
setShouldFocusInput(true) setShouldFocusInput(true)
@@ -970,7 +952,6 @@ export default function ChatPanel({
dict.dialogs.clearSuccess, dict.dialogs.clearSuccess,
buildSessionData, buildSessionData,
setDiagramHistory, setDiagramHistory,
pathname,
]) ])
const handleInputChange = ( const handleInputChange = (
@@ -1010,29 +991,6 @@ export default function ChatPanel({
} }
} }
// Handle stop button click
const handleStop = useCallback(() => {
const lastMessage = messages[messages.length - 1]
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming",
)
toolParts?.forEach((part: any) => {
if (part.toolCallId) {
addToolOutput({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
})
stop()
}, [messages, addToolOutput, stop])
// Send chat message with headers // Send chat message with headers
const sendChatMessage = ( const sendChatMessage = (
parts: any, parts: any,
@@ -1050,7 +1008,7 @@ export default function ChatPanel({
sendMessage( sendMessage(
{ parts }, { parts },
{ {
body: { xml, previousXml, sessionId, customSystemMessage }, body: { xml, previousXml, sessionId },
headers: { headers: {
"x-access-code": config.accessCode, "x-access-code": config.accessCode,
...(config.aiProvider && { ...(config.aiProvider && {
@@ -1399,7 +1357,6 @@ export default function ChatPanel({
status={status} status={status}
onSubmit={onFormSubmit} onSubmit={onFormSubmit}
onChange={handleInputChange} onChange={handleInputChange}
onStop={handleStop}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
pdfData={pdfData} pdfData={pdfData}
@@ -1410,8 +1367,8 @@ export default function ChatPanel({
models={modelConfig.models} models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId} selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId} onModelSelect={modelConfig.setSelectedModelId}
onConfigureModels={() => setShowModelConfigDialog(true)}
showUnvalidatedModels={modelConfig.showUnvalidatedModels} showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)}
shouldFocus={shouldFocusInput} shouldFocus={shouldFocusInput}
onFocused={() => setShouldFocusInput(false)} onFocused={() => setShouldFocusInput(false)}
/> />
@@ -1428,9 +1385,6 @@ export default function ChatPanel({
onMinimalStyleChange={setMinimalStyle} onMinimalStyleChange={setMinimalStyle}
vlmValidationEnabled={vlmValidationEnabled} vlmValidationEnabled={vlmValidationEnabled}
onVlmValidationChange={handleVlmValidationChange} onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
/> />
<ModelConfigDialog <ModelConfigDialog

View File

@@ -8,9 +8,9 @@ import {
Trash2, Trash2,
X, X,
} from "lucide-react" } from "lucide-react"
import Image from "next/image"
import { useState } from "react" import { useState } from "react"
import ExamplePanel from "@/components/chat-example-panel" import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,

View File

@@ -10,8 +10,8 @@ import {
RefreshCw, RefreshCw,
X, X,
} from "lucide-react" } from "lucide-react"
import Image from "next/image"
import { useState } from "react" import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"

View File

@@ -1,8 +1,8 @@
"use client" "use client"
import { FileCode, FileText, Link, Loader2, X } from "lucide-react" import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import Image from "next/image"
import { useState } from "react" import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,

View File

@@ -1,16 +0,0 @@
import NextImage, { type ImageProps } from "next/image"
import { forwardRef } from "react"
import { getAssetUrl } from "@/lib/base-path"
export default forwardRef<HTMLImageElement, ImageProps>(
function Image(props, ref) {
const src =
typeof props.src === "string" &&
props.src.startsWith("/") &&
!props.src.startsWith("//")
? getAssetUrl(props.src)
: props.src
return <NextImage {...props} src={src} ref={ref} />
},
)

View File

@@ -54,11 +54,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config" import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
PROVIDER_INFO,
PROVIDER_LOGO_MAP,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface ModelConfigDialogProps { interface ModelConfigDialogProps {
@@ -69,6 +65,24 @@ interface ModelConfigDialogProps {
type ValidationStatus = "idle" | "validating" | "success" | "error" type ValidationStatus = "idle" | "validating" | "success" | "error"
// Map provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
}
// Provider logo component // Provider logo component
function ProviderLogo({ function ProviderLogo({
provider, provider,
@@ -268,7 +282,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 isOllama = selectedProvider.provider === "ollama"
const isVertexAI = selectedProvider.provider === "vertexai" const isVertexAI = selectedProvider.provider === "vertexai"
if (isBedrock) { if (isBedrock) {
if ( if (
@@ -283,7 +296,7 @@ export function ModelConfigDialog({
if (!selectedProvider.vertexApiKey) { if (!selectedProvider.vertexApiKey) {
return return
} }
} else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) { } else if (!isEdgeOne && !selectedProvider.apiKey) {
return return
} }
@@ -430,12 +443,12 @@ export function ModelConfigDialog({
}} }}
className={cn( className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full", "group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150 border border-transparent", "text-left text-sm transition-all duration-150",
"hover:bg-interactive-hover", "hover:bg-interactive-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId === selectedProviderId ===
provider.id && provider.id &&
"bg-surface-0 shadow-sm border-border-subtle", "bg-surface-0 shadow-sm ring-1 ring-border-subtle",
)} )}
> >
<div <div
@@ -513,7 +526,7 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin"> <div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? ( {selectedProvider ? (
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="flex-1" ref={scrollRef}>
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
@@ -984,19 +997,10 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{formatMessage( Base URL{" "}
dict.modelConfig <span className="text-muted-foreground font-normal">
.baseUrlWithExample, (optional)
{ </span>
example:
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label> </Label>
<Input <Input
id="vertex-base-url" id="vertex-base-url"
@@ -1017,7 +1021,9 @@ export function ModelConfigDialog({
</div> </div>
</> </>
) : selectedProvider.provider === ) : selectedProvider.provider ===
"edgeone" ? ( "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
@@ -1085,9 +1091,6 @@ export function ModelConfigDialog({
dict.modelConfig dict.modelConfig
.apiKey .apiKey
} }
{selectedProvider.provider ===
"ollama" &&
` ${dict.modelConfig.optional}`}
</Label> </Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -1151,9 +1154,7 @@ export function ModelConfigDialog({
handleValidate handleValidate
} }
disabled={ disabled={
(selectedProvider.provider !== !selectedProvider.apiKey ||
"ollama" &&
!selectedProvider.apiKey) ||
validationStatus === validationStatus ===
"validating" "validating"
} }
@@ -1203,19 +1204,17 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{formatMessage( {
dict.modelConfig dict.modelConfig
.baseUrlWithExample, .baseUrl
}
<span className="text-muted-foreground font-normal">
{ {
example: dict
PROVIDER_INFO[ .modelConfig
selectedProvider .optional
.provider }
] </span>
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label> </Label>
<Input <Input
id="base-url" id="base-url"
@@ -1241,16 +1240,6 @@ export function ModelConfigDialog({
} }
className="h-9 rounded-xl font-mono text-xs" className="h-9 rounded-xl font-mono text-xs"
/> />
{selectedProvider.provider ===
"minimax" && (
<p className="text-xs text-muted-foreground">
{
dict
.modelConfig
.minimaxBaseUrlHint
}
</p>
)}
</div> </div>
</> </>
)} )}
@@ -1661,16 +1650,12 @@ export function ModelConfigDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="show-unvalidated-models"
checked={modelConfig.showUnvalidatedModels} checked={modelConfig.showUnvalidatedModels}
onCheckedChange={ onCheckedChange={
modelConfig.setShowUnvalidatedModels modelConfig.setShowUnvalidatedModels
} }
/> />
<Label <Label className="text-xs text-muted-foreground cursor-pointer">
htmlFor="show-unvalidated-models"
className="text-xs text-muted-foreground cursor-pointer"
>
{dict.modelConfig.showUnvalidatedModels} {dict.modelConfig.showUnvalidatedModels}
</Label> </Label>
</div> </div>

View File

@@ -27,21 +27,36 @@ import {
} from "@/components/ai-elements/model-selector" } from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { import type { FlattenedModel } from "@/lib/types/model-config"
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface ModelSelectorProps { interface ModelSelectorProps {
models: FlattenedModel[] models: FlattenedModel[]
selectedModelId: string | undefined selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure?: () => void onConfigure: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean showUnvalidatedModels?: boolean
} }
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
}
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider( function groupModelsByProvider(
models: FlattenedModel[], models: FlattenedModel[],
@@ -111,7 +126,9 @@ export function ModelSelector({
) )
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
if (value === "__server_default__") { if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
onSelect(undefined) onSelect(undefined)
} else { } else {
onSelect(value) onSelect(value)
@@ -197,241 +214,199 @@ export function ModelSelector({
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} placeholder={dict.modelConfig.searchModels}
/> />
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="flex-1 min-h-0 overflow-hidden"> <ModelSelectorEmpty>
<ModelSelectorList className="overflow-y-auto scrollbar-thin"> {displayModels.length === 0 && models.length > 0
<ModelSelectorEmpty> ? dict.modelConfig.noVerifiedModels
{displayModels.length === 0 && : dict.modelConfig.noModelsFound}
models.length > 0 </ModelSelectorEmpty>
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */} {/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && ( {serverModels.length === 0 && (
<ModelSelectorGroup <ModelSelectorGroup
heading={dict.modelConfig.default} heading={dict.modelConfig.default}
> >
<ModelSelectorItem <ModelSelectorItem
value="__server_default__" value="__server_default__"
onSelect={handleSelect} onSelect={handleSelect}
className={cn( className={cn(
"cursor-pointer", "cursor-pointer",
!selectedModelId && "bg-accent", !selectedModelId && "bg-accent",
)} )}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
> >
<Check {providerModels.map((model) => (
className={cn( <ModelSelectorItem
"mr-2 h-4 w-4", key={model.id}
!selectedModelId value={model.modelId}
? "opacity-100" onSelect={() =>
: "opacity-0", handleSelect(model.id)
)} }
/> className="cursor-pointer"
<Server className="mr-2 h-4 w-4 text-muted-foreground" /> >
<ModelSelectorName> <Check
{dict.modelConfig.serverDefault} className={cn(
</ModelSelectorName> "mr-2 h-4 w-4",
</ModelSelectorItem> selectedModelId ===
</ModelSelectorGroup> model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)} )}
</>
)}
{/* Server Models Section */} {/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && ( {serverModels.length > 0 && (
<> <ModelSelectorSeparator />
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict
.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict
.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)} )}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */} {/* Configure Option */}
{userModels.length > 0 && ( <ModelSelectorSeparator />
<> <ModelSelectorGroup>
{serverModels.length > 0 && ( <ModelSelectorItem
<ModelSelectorSeparator /> value="__configure__"
)} onSelect={handleSelect}
<ModelSelectorSectionHeader className="cursor-pointer text-muted-foreground hover:text-foreground"
icon={<User />} >
label={dict.modelConfig.userModels} <Settings2 className="mr-2 h-4 w-4" />
/> <ModelSelectorName>
{Array.from( {dict.modelConfig.configureModels}
groupedUserModels.entries(), </ModelSelectorName>
).map( </ModelSelectorItem>
([ </ModelSelectorGroup>
providerLabel, {/* Info text */}
{ <div className="px-3 py-2 text-xs text-muted-foreground border-t">
provider, {showUnvalidatedModels
models: providerModels, ? dict.modelConfig.allModelsShown
}, : dict.modelConfig.onlyVerifiedShown}
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict
.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
</div> </div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */} </ModelSelectorList>
<div className="relative z-10 shrink-0 border-t bg-background">
{onConfigure && (
<div className="px-3 py-2">
<ModelSelectorItem
value="__configure_models__"
onSelect={() => {
onConfigure()
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</div>
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</div>
</div>
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelectorRoot> </ModelSelectorRoot>
</div> </div>

View File

@@ -23,22 +23,9 @@ export function QuotaLimitToast({
}: QuotaLimitToastProps) { }: QuotaLimitToastProps) {
const dict = useDictionary() const dict = useDictionary()
const isTokenLimit = type === "token" const isTokenLimit = type === "token"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const formatNumber = (n: number) => const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString() n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit
? isSelfHosted
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -84,24 +71,19 @@ export function QuotaLimitToast({
</div> </div>
{/* Message */} {/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2"> <div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>{quotaMessage}</p> <p>
{!isSelfHosted && ( {isTokenLimit
<p ? dict.quota.messageToken
dangerouslySetInnerHTML={{ : dict.quota.messageApi}
__html: formatMessage( </p>
dict.quota.doubaoSponsorship,
{
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
},
),
}}
/>
)}
<p <p
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: tipHtml, __html: formatMessage(dict.quota.doubaoSponsorship, {
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
}),
}} }}
/> />
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p> <p>{dict.quota.reset}</p>
</div>{" "} </div>{" "}
{/* Action buttons */} {/* Action buttons */}
@@ -119,28 +101,24 @@ export function QuotaLimitToast({
{dict.quota.configModel} {dict.quota.configModel}
</button> </button>
)} )}
{!isSelfHosted && ( <a
<> href="https://github.com/DayuanJiang/next-ai-draw-io"
<a target="_blank"
href="https://github.com/DayuanJiang/next-ai-draw-io" rel="noopener noreferrer"
target="_blank" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
rel="noopener noreferrer" >
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors" <FaGithub className="w-3.5 h-3.5" />
> {dict.quota.selfHost}
<FaGithub className="w-3.5 h-3.5" /> </a>
{dict.quota.selfHost} <a
</a> href="https://github.com/sponsors/DayuanJiang"
<a target="_blank"
href="https://github.com/sponsors/DayuanJiang" rel="noopener noreferrer"
target="_blank" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
rel="noopener noreferrer" >
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors" <Coffee className="w-3.5 h-3.5" />
> {dict.quota.sponsor}
<Coffee className="w-3.5 h-3.5" /> </a>
{dict.quota.sponsor}
</a>
</>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react" import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -22,7 +22,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
@@ -57,7 +56,6 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English", en: "English",
zh: "中文", zh: "中文",
ja: "日本語", ja: "日本語",
"zh-Hant": "繁體中文",
} }
interface SettingsDialogProps { interface SettingsDialogProps {
@@ -71,9 +69,6 @@ interface SettingsDialogProps {
onMinimalStyleChange?: (value: boolean) => void onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void onVlmValidationChange?: (value: boolean) => void
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -97,9 +92,6 @@ function SettingsContent({
onMinimalStyleChange = () => {}, onMinimalStyleChange = () => {},
vlmValidationEnabled = false, vlmValidationEnabled = false,
onVlmValidationChange = () => {}, onVlmValidationChange = () => {},
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
@@ -279,7 +271,7 @@ function SettingsContent({
} }
return ( return (
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden"> <DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */} {/* Header */}
<DialogHeader className="px-6 pt-6 pb-4"> <DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogTitle>{dict.settings.title}</DialogTitle>
@@ -289,29 +281,8 @@ function SettingsContent({
</DialogHeader> </DialogHeader>
{/* Content */} {/* Content */}
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin"> <div className="px-6 pb-6">
<div className="divide-y divide-border-subtle"> <div className="divide-y divide-border-subtle">
{/* API Keys & Models */}
{onOpenModelConfig && (
<SettingItem
label={dict.settings.apiKeysModels}
description={dict.settings.apiKeysModelsDescription}
>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
onClick={() => {
onOpenChange(false)
onOpenModelConfig()
}}
aria-label={dict.settings.apiKeysModels}
>
<ChevronRight className="h-4 w-4" />
</Button>
</SettingItem>
)}
{/* Access Code (conditional) */} {/* Access Code (conditional) */}
{accessCodeRequired && ( {accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3"> <div className="py-4 first:pt-0 space-y-3">
@@ -462,33 +433,6 @@ function SettingsContent({
</div> </div>
</SettingItem> </SettingItem>
{/* Custom System Message */}
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="custom-system-message"
className="text-sm font-medium"
>
{dict.settings.customSystemMessage}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.customSystemMessageDescription}
</p>
</div>
<Textarea
id="custom-system-message"
value={customSystemMessage}
onChange={(e) =>
onCustomSystemMessageChange(e.target.value)
}
placeholder={
dict.settings.customSystemMessagePlaceholder
}
className="min-h-[80px] max-h-[160px] text-sm"
maxLength={5000}
/>
</div>
{/* Send Shortcut */} {/* Send Shortcut */}
<SettingItem <SettingItem
label={dict.settings.sendShortcut} label={dict.settings.sendShortcut}
@@ -511,7 +455,7 @@ function SettingsContent({
> >
<SelectTrigger <SelectTrigger
id="send-shortcut-select" id="send-shortcut-select"
className="w-auto h-9 rounded-xl" className="w-[170px] h-9 rounded-xl"
> >
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -77,13 +77,12 @@ function CommandInput({
) )
} }
const CommandList = React.forwardRef< function CommandList({
React.ElementRef<typeof CommandPrimitive.List>, className,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> ...props
>(({ className, ...props }, ref) => { }: React.ComponentProps<typeof CommandPrimitive.List>) {
return ( return (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref}
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
{...props} {...props}
/> />
) )
}) }
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
function CommandEmpty({ function CommandEmpty({
...props ...props

View File

@@ -20,10 +20,9 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null> drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -57,6 +56,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const pngResolverRef = useRef<((value: string) => void) | null>(null) const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
@@ -78,22 +79,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}, [chartXML]) }, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change) // Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
const lastRestoredXmlRef = useRef<string>("")
useEffect(() => { useEffect(() => {
if (!isDrawioReady || !drawioRef.current) return // Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
// Only load if we have a real diagram and it's different from what we already loaded if (!isDrawioReady) {
if ( hasDiagramRestoredRef.current = false
isRealDiagram(chartXML) && return
chartXML !== lastRestoredXmlRef.current
) {
lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML })
} else if (!isRealDiagram(chartXML)) {
// Reset when diagram is cleared so a future restore can re-load the same XML.
lastRestoredXmlRef.current = ""
} }
}, [isDrawioReady, chartXML]) // Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
@@ -266,16 +267,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
setChartXML(data.xml)
}
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -400,7 +391,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

View File

@@ -19,9 +19,7 @@
一个集成了AI功能的Next.js网页应用与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。 一个集成了AI功能的Next.js网页应用与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型! > 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979 https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -197,7 +195,7 @@ npm run dev
## 多提供商支持 ## 多提供商支持
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) - [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock默认 - AWS Bedrock默认
- OpenAI - OpenAI
- Anthropic - Anthropic
@@ -238,7 +236,7 @@ npm run dev
## 支持与联系 ## 支持与联系
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token **特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点! 如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!

View File

@@ -13,7 +13,7 @@
### 豆包 (字节跳动火山引擎) ### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token > **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
@@ -207,85 +207,6 @@ AI_MODEL=openai/gpt-4o
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。 从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
### MiniMax
MiniMax 支持两种 API 格式:
- **Anthropic 兼容**`/anthropic` 端点)— 推荐,支持 interleaved thinking
- **OpenAI 兼容**`/v1` 端点)— 标准 OpenAI 聊天补全格式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M2.7
```
可选配置:
```bash
# 中国大陆版Anthropic 兼容(默认)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陆版OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国际版Anthropic 兼容
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国际版OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (智谱 AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
可选的自定义端点:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (阿里云通义千问)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
可选的自定义端点:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (月之暗面 Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
可选的自定义端点:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (七牛云)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
可选的自定义端点:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## 自动检测 ## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER` 如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`
@@ -293,7 +214,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER` 如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash ```bash
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
``` ```
## 服务端多模型配置 ## 服务端多模型配置

View File

@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
### Doubao (ByteDance Volcengine) ### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models! > **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
@@ -222,85 +222,6 @@ Model format uses `provider/model` syntax:
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway). Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
### MiniMax
MiniMax supports two API formats:
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M2.7
```
Optional configuration:
```bash
# China mainland, Anthropic-compatible (default)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# China mainland, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# International, Anthropic-compatible
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# International, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
Optional custom endpoint:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
Optional custom endpoint:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
Optional custom endpoint:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
Optional custom endpoint:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## Auto-Detection ## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`. If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -308,7 +229,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
``` ```
## Server-Side Multi-Model Configuration ## Server-Side Multi-Model Configuration

View File

@@ -19,7 +19,7 @@
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。 AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました! > 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979 https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -196,7 +196,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
## マルチプロバイダーサポート ## マルチプロバイダーサポート
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) - [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrockデフォルト - AWS Bedrockデフォルト
- OpenAI - OpenAI
- Anthropic - Anthropic
@@ -237,7 +237,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
## サポート&お問い合わせ ## サポート&お問い合わせ
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます **デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください! このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!

View File

@@ -13,7 +13,7 @@
### Doubao (ByteDance Volcengine) ### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます > **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
@@ -207,85 +207,6 @@ AI_MODEL=openai/gpt-4o
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。 [Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
### MiniMax
MiniMax は 2 つの API 形式をサポートしています:
- **Anthropic 互換**`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
- **OpenAI 互換**`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M2.7
```
オプション設定:
```bash
# 中国大陸版、Anthropic 互換(デフォルト)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陸版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国際版、Anthropic 互換
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国際版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
オプションのカスタムエンドポイント:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
オプションのカスタムエンドポイント:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
オプションのカスタムエンドポイント:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
オプションのカスタムエンドポイント:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## 自動検出 ## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。 **1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -293,7 +214,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります: **複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash ```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
``` ```
## サーバーサイドマルチモデル設定 ## サーバーサイドマルチモデル設定

View File

@@ -1,367 +0,0 @@
# material_design
**Type:** SVG images (Google Material Icons CDN)
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
</mxCell>
```
Replace `{icon_name}` with any icon name from the list below.
## action (115)
- `account_balance`
- `account_balance_wallet`
- `account_box`
- `account_circle`
- `add_shopping_cart`
- `admin_panel_settings`
- `analytics`
- `arrow_right_alt`
- `article`
- `assessment`
- `assignment`
- `assignment_ind`
- `assignment_turned_in`
- `autorenew`
- `bookmark`
- `bookmark_border`
- `build`
- `calendar_month`
- `calendar_today`
- `card_giftcard`
- `check_circle`
- `check_circle_outline`
- `code`
- `contact_support`
- `credit_card`
- `dashboard`
- `date_range`
- `delete`
- `delete_forever`
- `delete_outline`
- `description`
- `dns`
- `done`
- `done_all`
- `done_outline`
- `drag_indicator`
- `event`
- `exit_to_app`
- `explore`
- `face`
- `fact_check`
- `favorite`
- `favorite_border`
- `feedback`
- `filter_alt`
- `fingerprint`
- `flight_takeoff`
- `grade`
- `help`
- `help_outline`
- `highlight_off`
- `history`
- `home`
- `info`
- `label`
- `language`
- `launch`
- `leaderboard`
- `lightbulb`
- `list`
- `lock`
- `lock_open`
- `login`
- `logout`
- `manage_accounts`
- `note_add`
- `open_in_full`
- `open_in_new`
- `paid`
- `payment`
- `pending`
- `pending_actions`
- `perm_identity`
- `pets`
- `power_settings_new`
- `preview`
- `print`
- `published_with_changes`
- `question_answer`
- `receipt`
- `reorder`
- `report_problem`
- `room`
- `savings`
- `schedule`
- `search`
- `settings`
- `shopping_bag`
- `shopping_basket`
- `shopping_cart`
- `star_rate`
- `stars`
- `store`
- `supervisor_account`
- `swap_horiz`
- `sync_alt`
- `task_alt`
- `thumb_up`
- `thumb_up_off_alt`
- `timeline`
- `tips_and_updates`
- `today`
- `touch_app`
- `trending_up`
- `update`
- `verified`
- `verified_user`
- `view_in_ar`
- `view_list`
- `visibility`
- `visibility_off`
- `watch_later`
- `work`
- `work_outline`
- `zoom_in`
## alert (4)
- `error`
- `error_outline`
- `warning`
- `warning_amber`
## av (12)
- `library_books`
- `mic`
- `pause`
- `play_arrow`
- `play_circle`
- `play_circle_filled`
- `play_circle_outline`
- `replay`
- `skip_next`
- `videocam`
- `volume_off`
- `volume_up`
## communication (13)
- `alternate_email`
- `business`
- `call`
- `chat`
- `chat_bubble_outline`
- `email`
- `forum`
- `list_alt`
- `location_on`
- `mail_outline`
- `phone`
- `qr_code_scanner`
- `vpn_key`
## content (27)
- `add`
- `add_box`
- `add_circle`
- `add_circle_outline`
- `block`
- `bolt`
- `calculate`
- `clear`
- `content_copy`
- `create`
- `filter_list`
- `flag`
- `how_to_reg`
- `insights`
- `inventory`
- `inventory_2`
- `link`
- `mail`
- `push_pin`
- `remove`
- `remove_circle`
- `remove_circle_outline`
- `reply`
- `save`
- `send`
- `sort`
- `undo`
## device (9)
- `dark_mode`
- `devices`
- `light_mode`
- `password`
- `restart_alt`
- `sell`
- `signal_cellular_alt`
- `summarize`
- `task`
## editor (9)
- `attach_file`
- `attach_money`
- `bar_chart`
- `checklist`
- `edit_note`
- `format_list_bulleted`
- `mode_edit`
- `monetization_on`
- `post_add`
## file (8)
- `cloud_upload`
- `download`
- `file_download`
- `file_upload`
- `folder`
- `folder_open`
- `grid_view`
- `upload_file`
## hardware (6)
- `computer`
- `keyboard_arrow_down`
- `keyboard_arrow_right`
- `phone_iphone`
- `security`
- `smartphone`
## image (16)
- `add_a_photo`
- `auto_awesome`
- `auto_stories`
- `circle`
- `collections`
- `edit`
- `image`
- `navigate_before`
- `navigate_next`
- `palette`
- `photo_camera`
- `picture_as_pdf`
- `receipt_long`
- `remove_red_eye`
- `timer`
- `tune`
## maps (11)
- `badge`
- `category`
- `directions_car`
- `local_fire_department`
- `local_offer`
- `local_shipping`
- `map`
- `menu_book`
- `place`
- `restaurant`
- `volunteer_activism`
## navigation (29)
- `apps`
- `arrow_back`
- `arrow_back_ios`
- `arrow_back_ios_new`
- `arrow_downward`
- `arrow_drop_down`
- `arrow_drop_up`
- `arrow_forward`
- `arrow_forward_ios`
- `arrow_right`
- `arrow_upward`
- `campaign`
- `cancel`
- `check`
- `chevron_left`
- `chevron_right`
- `close`
- `double_arrow`
- `east`
- `expand_less`
- `expand_more`
- `fullscreen`
- `menu`
- `menu_open`
- `more_horiz`
- `more_vert`
- `payments`
- `refresh`
- `unfold_more`
## notification (6)
- `account_tree`
- `event_available`
- `priority_high`
- `support_agent`
- `sync`
- `wifi`
## places (2)
- `apartment`
- `storefront`
## search (2)
- `feed`
- `manage_search`
## social (23)
- `construction`
- `emoji_emotions`
- `emoji_events`
- `engineering`
- `group`
- `group_add`
- `groups`
- `health_and_safety`
- `notifications`
- `notifications_active`
- `notifications_none`
- `people`
- `people_alt`
- `person`
- `person_add`
- `person_outline`
- `psychology`
- `public`
- `school`
- `share`
- `thumb_up_alt`
- `travel_explore`
- `water_drop`
## toggle (8)
- `check_box`
- `check_box_outline_blank`
- `radio_button_checked`
- `radio_button_unchecked`
- `star`
- `star_border`
- `star_outline`
- `toggle_on`
Total: 300 icons (top by popularity from 2100+ available)

View File

@@ -10,13 +10,8 @@ directories:
afterPack: ./scripts/afterPack.cjs afterPack: ./scripts/afterPack.cjs
files: files:
- from: dist-electron - dist-electron/**/*
to: dist-electron - "!node_modules"
filter:
- "**/*"
- from: .
filter:
- package.json
asarUnpack: asarUnpack:
- "**/*.node" - "**/*.node"
@@ -42,11 +37,10 @@ mac:
arch: arch:
- x64 - x64
- arm64 - arm64
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack hardenedRuntime: true
# to properly sign nested bundles with --deep flag for bundled draw.io files
identity: null
hardenedRuntime: false
gatekeeperAssess: false gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg: dmg:
contents: contents:

View File

@@ -69,9 +69,7 @@ declare global {
/** Set proxy configuration (saves and restarts server) */ /** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult> setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */ /** Get user's preferred locale */
getUserLocale: () => Promise< getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
"en" | "zh" | "ja" | "zh-Hant" | undefined
>
/** Set user's preferred locale */ /** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult> setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
} }

View File

@@ -137,7 +137,7 @@ interface ConfigPresetsFile {
version: 1 version: 1
currentPresetId: string | null currentPresetId: string | null
presets: ConfigPreset[] presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja" | "zh-Hant" userLocale?: "en" | "zh" | "ja"
} }
const CONFIG_FILE_NAME = "config-presets.json" const CONFIG_FILE_NAME = "config-presets.json"
@@ -359,9 +359,9 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
baseUrl: "MODELSCOPE_BASE_URL", baseUrl: "MODELSCOPE_BASE_URL",
}, },
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" }, gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock doesn't use API keys in the same way // bedrock and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" }, bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" }, ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
} }
/** /**
@@ -470,7 +470,7 @@ export function getCurrentPresetEnv(): Record<string, string> {
* Get user's preferred locale from config * Get user's preferred locale from config
* Returns undefined if not set * Returns undefined if not set
*/ */
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined { export function getUserLocale(): "en" | "zh" | "ja" | undefined {
const data = loadPresets() const data = loadPresets()
return data.userLocale return data.userLocale
} }
@@ -478,9 +478,7 @@ export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
/** /**
* Set user's preferred locale in config * Set user's preferred locale in config
*/ */
export function setUserLocale( export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
): void {
const data = loadPresets() const data = loadPresets()
data.userLocale = locale === null ? undefined : locale data.userLocale = locale === null ? undefined : locale
savePresets(data) savePresets(data)

View File

@@ -94,8 +94,7 @@ if (!gotTheLock) {
if ( if (
url.includes("diagrams.net") || url.includes("diagrams.net") ||
url.includes("draw.io") || url.includes("draw.io") ||
url.startsWith("http://localhost") || url.startsWith("http://localhost")
url.startsWith("http://127.0.0.1")
) { ) {
return { action: "allow" } return { action: "allow" }
} }

View File

@@ -263,12 +263,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle("set-user-locale", (_event, locale: string) => { ipcMain.handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values // Validate locale is one of the supported values
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) { if (!["en", "zh", "ja"].includes(locale)) {
return { success: false, error: "Invalid locale" } return { success: false, error: "Invalid locale" }
} }
try { try {
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant") setUserLocale(locale as "en" | "zh" | "ja")
// Rebuild the menu to reflect the new locale // Rebuild the menu to reflect the new locale
rebuildAppMenu() rebuildAppMenu()
return { success: true } return { success: true }

View File

@@ -5,7 +5,7 @@
import { getUserLocale } from "./config-manager" import { getUserLocale } from "./config-manager"
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant" export type MenuLocale = "en" | "zh" | "ja"
export interface MenuTranslations { export interface MenuTranslations {
// App menu (macOS only) // App menu (macOS only)
@@ -119,34 +119,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
documentation: "ドキュメント", documentation: "ドキュメント",
reportIssue: "問題を報告", reportIssue: "問題を報告",
}, },
"zh-Hant": {
// App menu
settings: "設定...",
// File menu
file: "檔案",
// Edit menu
edit: "編輯",
// View menu
view: "檢視",
// Configuration menu
configuration: "配置",
switchPreset: "切換預設",
managePresets: "管理預設...",
addConfigurationPreset: "新增配置預設...",
// Window menu
window: "視窗",
// Help menu
help: "說明",
documentation: "文件",
reportIssue: "回報問題",
},
} }
/** /**
@@ -154,16 +126,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
* Falls back to English if locale is not supported * Falls back to English if locale is not supported
*/ */
export function getMenuTranslations(locale: string): MenuTranslations { export function getMenuTranslations(locale: string): MenuTranslations {
// Check for zh-Hant before normalizing
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja") // Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0] const normalized = locale.toLowerCase().split("-")[0]
@@ -174,21 +136,10 @@ export function getMenuTranslations(locale: string): MenuTranslations {
/** /**
* Detect system locale from Electron app * Detect system locale from Electron app
* Returns one of: "en", "zh", "ja", "zh-Hant" * Returns one of: "en", "zh", "ja"
*/ */
export function detectSystemLocale(appLocale: string): MenuLocale { export function detectSystemLocale(appLocale: string): MenuLocale {
const lower = appLocale.toLowerCase() const normalized = appLocale.toLowerCase().split("-")[0]
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
if (
lower.startsWith("zh-hant") ||
lower.startsWith("zh-tw") ||
lower.startsWith("zh-hk")
) {
return "zh-Hant"
}
const normalized = lower.split("-")[0]
if (normalized === "zh") return "zh" if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja" if (normalized === "ja") return "ja"

View File

@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
const env: Record<string, string> = { const env: Record<string, string> = {
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", HOSTNAME: "localhost",
// Enable Node.js built-in proxy support for fetch (Node.js 24+) // Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1", NODE_USE_ENV_PROXY: "1",
} }

View File

@@ -9,11 +9,9 @@ import { app } from "electron"
const PORT_CONFIG = { const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility // Development mode uses fixed port for hot reload compatibility
development: 6002, development: 6002,
// Legacy production port — tried first to preserve localStorage for existing users // Production mode uses fixed port (61337) to preserve localStorage
legacyProduction: 61337, // Falls back to sequential ports if unavailable
// New production port below the ephemeral range (49152-65535) production: 61337,
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
production: 13370,
// Maximum attempts to find an available port (fallback) // Maximum attempts to find an available port (fallback)
maxAttempts: 100, maxAttempts: 100,
} }
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
export function isPortAvailable(port: number): Promise<boolean> { export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer() const server = net.createServer()
server.once("error", (err: NodeJS.ErrnoException) => { server.once("error", () => resolve(false))
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("listening", () => { server.once("listening", () => {
server.close() server.close()
resolve(true) resolve(true)
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: uses fixed port (13370) to preserve localStorage * - In production: uses fixed port (61337) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable * - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
* *
* @param reuseExisting If true, try to reuse the previously allocated port * @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port * @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const isDev = !app.isPackaged
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// In production, try legacy port first to preserve existing users' localStorage // Try preferred port first
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) { if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort allocatedPort = preferredPort
return preferredPort return preferredPort
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
} }
} }
// Last resort: let the OS pick an available port throw new Error(
console.warn( `Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
) )
const osPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.once("listening", () => {
const addr = server.address()
const port = (addr as net.AddressInfo).port
server.close(() => resolve(port))
})
server.listen(0, "127.0.0.1")
})
allocatedPort = osPort
console.log(`OS assigned port: ${osPort}`)
return osPort
} }
/** /**
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.", "No port allocated yet. Call findAvailablePort() first.",
) )
} }
return `http://127.0.0.1:${allocatedPort}` return `http://localhost:${allocatedPort}`
} }

View File

@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
// Handle page title updates // Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => { mainWindow.webContents.on("page-title-updated", (event, title) => {
if ( if (title && !title.includes("localhost")) {
title &&
!title.includes("localhost") &&
!title.includes("127.0.0.1")
) {
mainWindow?.setTitle(title) mainWindow?.setTitle(title)
} else { } else {
event.preventDefault() event.preventDefault()

View File

@@ -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, novita # Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -59,9 +59,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high) # AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
# AZURE_REASONING_SUMMARY=detailed # AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud) # Ollama (Local) Configuration
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud # OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3) # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
# OpenRouter Configuration # OpenRouter Configuration
@@ -135,40 +134,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Allow private/internal URLs for reverse proxy setups (default: true) # Allow private/internal URLs for reverse proxy setups (default: true)
# Set to "false" to block private IPs, localhost, and internal hostnames # Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false # ALLOW_PRIVATE_URLS=false
# Self-hosted deployment (Optional)
# Self-hosted users may implement custom quota-management solutions,
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
# NEXT_PUBLIC_SELFHOSTED=true
# Minimax Configuration (Optional)
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
# MINIMAX_API_KEY=your_minimax_api_key
# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland)
# GLM Configuration (Optional)
# Get your API key from: https://open.bigmodel.cn/dev/api
# GLM_API_KEY=your_glm_api_key
# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default
# Qwen Configuration (Optional)
# Get your API key from: https://www.aliyun.com/product/bailian
# QWEN_API_KEY=your_qwen_api_key
# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default
# Kimi Configuration (Optional)
# Get your API key from: https://platform.moonshot.cn/
# KIMI_API_KEY=your_kimi_api_key
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default
# Qiniu Configuration (Optional)
# Get your API key from: https://www.qiniu.com/ai/models
# QINIU_API_KEY=your_qiniu_api_key
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
# Novita AI Configuration (Optional)
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import type { FlattenedServerModel } from "@/lib/server-model-config" import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import { import {
@@ -148,7 +147,7 @@ export function useModelConfig(): UseModelConfigReturn {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
fetch(getApiEndpoint("/api/server-models")) fetch("/api/server-models")
.then((res) => { .then((res) => {
if (!res.ok) { if (!res.ok) {
console.error( console.error(

View File

@@ -9,7 +9,7 @@ import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2" import { createOllama, ollama } from "ollama-ai-provider-v2"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" import type { ProviderName } from "@/lib/types/model-config"
export type { ProviderName } export type { ProviderName }
@@ -18,43 +18,6 @@ interface ModelConfig {
providerOptions?: any providerOptions?: any
headers?: Record<string, string> headers?: Record<string, string>
modelId: string modelId: string
provider: ProviderName
}
// Providers that only support a single system message
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
"minimax",
"glm",
"qwen",
"kimi",
"qiniu",
"novita",
])
/**
* Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
*/
export function normalizeMiniMaxBaseURL(rawUrl: string): {
baseURL: string
isAnthropicCompatible: boolean
} {
const isAnthropicCompatible = rawUrl.includes("/anthropic")
let baseURL = rawUrl.replace(/\/$/, "")
if (isAnthropicCompatible) {
if (!baseURL.endsWith("/anthropic/v1")) {
if (baseURL.endsWith("/anthropic")) {
baseURL = `${baseURL}/v1`
} else {
baseURL = `${baseURL}/anthropic/v1`
}
}
} else {
if (!baseURL.endsWith("/v1")) {
baseURL = `${baseURL}/v1`
}
}
return { baseURL, isAnthropicCompatible }
} }
export interface ClientOverrides { export interface ClientOverrides {
@@ -71,13 +34,12 @@ export interface ClientOverrides {
vertexApiKey?: string | null // Express Mode API key vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth) // Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string> headers?: Record<string, string>
// Custom env var name(s) for server models // Custom env var names for server models (allows multiple API keys per provider)
// Can be a single string or array of strings for load balancing apiKeyEnv?: string
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
// Providers that can be selected from client settings // Providers that can be used with client-provided API keys
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openai", "openai",
"anthropic", "anthropic",
@@ -91,15 +53,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"sglang", "sglang",
"gateway", "gateway",
"edgeone", "edgeone",
"ollama",
"doubao", "doubao",
"modelscope", "modelscope",
"glm",
"qwen",
"qiniu",
"kimi",
"minimax",
"novita",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -143,12 +98,10 @@ export function resolveBaseURL(
/** /**
* Resolve API key from custom env var name or default env var. * Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config. * Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
* When multiple keys are configured, randomly selects one for load balancing.
* *
* Priority: * Priority:
* 1. User-provided API key (overrides.apiKey) * 1. User-provided API key (overrides.apiKey)
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv) * 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* - If array, randomly picks one with a valid value
* 3. Default provider env var (defaultEnvVar) * 3. Default provider env var (defaultEnvVar)
*/ */
function resolveApiKey( function resolveApiKey(
@@ -156,30 +109,7 @@ function resolveApiKey(
defaultEnvVar: string, defaultEnvVar: string,
): string | undefined { ): string | undefined {
if (overrides?.apiKey) return overrides.apiKey if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
if (overrides?.apiKeyEnv) {
// Handle array of env var names - randomly select one
if (Array.isArray(overrides.apiKeyEnv)) {
// Filter to only env vars that have values
const validEnvVars = overrides.apiKeyEnv.filter(
(envVar) => process.env[envVar],
)
if (validEnvVars.length > 0) {
// Randomly select one
const selectedEnvVar =
validEnvVars[
Math.floor(Math.random() * validEnvVars.length)
]
console.log(
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
)
return process.env[selectedEnvVar]
}
} else {
return process.env[overrides.apiKeyEnv]
}
}
return process.env[defaultEnvVar] return process.env[defaultEnvVar]
} }
@@ -517,13 +447,7 @@ function buildProviderOptions(
case "sglang": case "sglang":
case "gateway": case "gateway":
case "modelscope": case "modelscope":
case "doubao": case "doubao": {
case "minimax":
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
break break
@@ -553,12 +477,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
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", modelscope: "MODELSCOPE_API_KEY",
glm: "GLM_API_KEY",
qwen: "QWEN_API_KEY",
qiniu: "QINIU_API_KEY",
kimi: "KIMI_API_KEY",
minimax: "MINIMAX_API_KEY",
novita: "NOVITA_API_KEY",
} }
/** /**
@@ -597,24 +515,12 @@ 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 provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv) * @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
*/ */
function validateProviderCredentials( function validateProviderCredentials(
provider: ProviderName, provider: ProviderName,
customApiKeyEnv?: string | string[], customApiKeyEnv?: string,
): void { ): void {
// Handle array of env var names - at least one must be set
if (Array.isArray(customApiKeyEnv)) {
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
if (!hasAnyKey) {
throw new Error(
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
`Please set at least one in your .env.local file.`,
)
}
return
}
// Use custom env var name if provided, otherwise use default // Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider] const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
@@ -651,7 +557,7 @@ function validateProviderCredentials(
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to http://localhost:11434)
* - OPENROUTER_API_KEY: OpenRouter API key * - OPENROUTER_API_KEY: OpenRouter API key
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
@@ -666,15 +572,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided. // If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints. // This prevents attackers from redirecting server API keys to malicious endpoints.
// Exception: EdgeOne doesn't require API keys. // Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
if ( if (
overrides?.baseUrl && overrides?.baseUrl &&
!overrides?.apiKey && !overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone" && overrides?.provider !== "edgeone"
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
) { ) {
throw new Error( throw new Error(
`API key is required when using a custom base URL. ` + `API key is required when using a custom base URL. ` +
@@ -933,27 +836,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "ollama": { case "ollama":
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL if (process.env.OLLAMA_BASE_URL) {
// SECURITY: When client provides a custom base URL, only use
// client-provided API key. Never fall back to server OLLAMA_API_KEY
// to prevent leaking server credentials to user-controlled endpoints.
const apiKey = overrides?.baseUrl
? overrides?.apiKey || undefined
: resolveApiKey(overrides, "OLLAMA_API_KEY")
if (baseURL || apiKey) {
const customOllama = createOllama({ const customOllama = createOllama({
...(baseURL && { baseURL }), baseURL: process.env.OLLAMA_BASE_URL,
...(apiKey && {
headers: { Authorization: `Bearer ${apiKey}` },
}),
}) })
model = customOllama(modelId) model = customOllama(modelId)
} else { } else {
model = ollama(modelId) model = ollama(modelId)
} }
break break
}
case "openrouter": { case "openrouter": {
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY") const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
@@ -1227,70 +1119,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "minimax": {
const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"MINIMAX_BASE_URL",
)
const rawBaseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
PROVIDER_INFO.minimax.defaultBaseUrl,
)
if (!rawBaseURL) {
throw new Error(
"MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.",
)
}
const { baseURL, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawBaseURL)
if (isAnthropicCompatible) {
const minimax = createAnthropic({ apiKey, baseURL })
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({ apiKey, baseURL })
model = minimax.chat(modelId)
}
break
}
case "glm":
case "qwen":
case "qiniu":
case "kimi":
case "novita": {
const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) {
throw new Error(
`API key environment variable not defined for provider: ${provider}`,
)
}
const apiKey = resolveApiKey(overrides, envVar)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(
overrides,
`${provider.toUpperCase()}_BASE_URL`,
),
PROVIDER_INFO[provider]?.defaultBaseUrl,
)
const customProvider = createOpenAI({
apiKey,
baseURL,
})
model = customProvider.chat(modelId)
break
}
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, glm, qwen, qiniu, kimi, minimax, novita`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
) )
} }
@@ -1299,7 +1130,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
providerOptions = customProviderOptions providerOptions = customProviderOptions
} }
return { model, providerOptions, headers, modelId, provider } return { model, providerOptions, headers, modelId }
} }
/** /**
@@ -1328,25 +1159,8 @@ export function supportsImageInput(modelId: string): boolean {
lowerModelId.includes("vision") || lowerModelId.includes("vl") lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant) // Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 doesn't support images, but K2.5 does // Kimi K2 models don't support images
// Only block kimi-k2 specifically, not other Kimi models if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
if (
(lowerModelId.includes("kimi-k2") ||
lowerModelId.includes("kimi_k2")) &&
!hasVisionIndicator &&
!lowerModelId.includes("2.5") &&
!lowerModelId.includes("k2.5")
) {
return false
}
// Moonshot text models (moonshot-v1 series are text-only)
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
return false
}
// MiniMax text models (MiniMax-M2.x series are text-only)
if (lowerModelId.includes("minimax") && !hasVisionIndicator) {
return false return false
} }
@@ -1356,24 +1170,10 @@ export function supportsImageInput(modelId: string): boolean {
} }
// Qwen text models (not vision variants like qwen-vl) // Qwen text models (not vision variants like qwen-vl)
// qwen3.5-plus is a vision model if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5-plus") &&
!lowerModelId.includes("qwen3.5-flash")
) {
return false return false
} }
// GLM text models (not vision variants)
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
if (!/[\d.]v/.test(lowerModelId)) {
return false
}
}
// Default: assume model supports images // Default: assume model supports images
return true return true
} }

View File

@@ -1,6 +1,6 @@
export const i18n = { export const i18n = {
defaultLocale: "en", defaultLocale: "en",
locales: ["en", "zh", "ja", "zh-Hant"], locales: ["en", "zh", "ja"],
} as const } as const
export type Locale = (typeof i18n)["locales"][number] export type Locale = (typeof i18n)["locales"][number]

View File

@@ -6,8 +6,6 @@ const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default), en: () => import("./dictionaries/en.json").then((m) => m.default),
zh: () => import("./dictionaries/zh.json").then((m) => m.default), zh: () => import("./dictionaries/zh.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default), ja: () => import("./dictionaries/ja.json").then((m) => m.default),
"zh-Hant": () =>
import("./dictionaries/zh-Hant.json").then((m) => m.default),
} }
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>> export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>

View File

@@ -29,17 +29,12 @@
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow",
"modelscope": "ModelScope", "modelscope": "ModelScope"
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
}, },
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
"send": "Send", "send": "Send",
"stopGeneration": "Stop generation", "sending": "Sending...",
"sendMessage": "Send message", "sendMessage": "Send message",
"clearConversation": "Clear conversation", "clearConversation": "Clear conversation",
"diagramHistory": "Diagram history", "diagramHistory": "Diagram history",
@@ -82,8 +77,6 @@
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"description": "Configure your application settings.", "description": "Configure your application settings.",
"apiKeysModels": "API Keys & Models",
"apiKeysModelsDescription": "Configure AI providers and API keys.",
"accessCode": "Access Code", "accessCode": "Access Code",
"accessCodePlaceholder": "Enter access code", "accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.", "accessCodeDescription": "Required to use this application.",
@@ -126,10 +119,7 @@
"diagramValidation": "Diagram Validation (Experimental)", "diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.", "diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled"
"customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams..."
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -187,11 +177,8 @@
"tpmMessage": "Too many requests. Please wait a moment.", "tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.", "tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.", "messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"messageApiSelfHosted": null,
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.", "messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"messageTokenSelfHosted": null,
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.", "tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
"reset": "Your limit resets tomorrow. Thanks for understanding.", "reset": "Your limit resets tomorrow. Thanks for understanding.",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.", "doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
"configModel": "Use Your API Key", "configModel": "Use Your API Key",
@@ -315,9 +302,7 @@
"enterSecretKey": "Enter your secret access key", "enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL", "baseUrl": "Base URL",
"optional": "(optional)", "optional": "(optional)",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL", "customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"models": "Models", "models": "Models",
"customModelId": "Custom model ID...", "customModelId": "Custom model ID...",
"allAdded": "All added", "allAdded": "All added",

View File

@@ -29,17 +29,12 @@
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow",
"modelscope": "ModelScope", "modelscope": "ModelScope"
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
}, },
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"send": "送信", "send": "送信",
"stopGeneration": "生成を停止", "sending": "送信中...",
"sendMessage": "メッセージを送信", "sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア", "clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴", "diagramHistory": "ダイアグラム履歴",
@@ -82,8 +77,6 @@
"settings": { "settings": {
"title": "設定", "title": "設定",
"description": "アプリケーション設定を構成します。", "description": "アプリケーション設定を構成します。",
"apiKeysModels": "API キーとモデル",
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
"accessCode": "アクセスコード", "accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力", "accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。", "accessCodeDescription": "このアプリケーションを使用するために必要です。",
@@ -126,10 +119,7 @@
"diagramValidation": "ダイアグラム検証(実験的)", "diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。", "diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効", "enabled": "有効",
"disabled": "無効", "disabled": "無効"
"customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..."
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -187,11 +177,8 @@
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。", "tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。", "tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageApiSelfHosted": null,
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageTokenSelfHosted": null,
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。", "tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます。", "reset": "制限は明日リセットされます。ご理解ありがとうございます。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデルDoubao、DeepSeek、Kimi含むで50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。", "doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデルDoubao、DeepSeek、Kimi含むで50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "APIキーを使用", "configModel": "APIキーを使用",
@@ -315,9 +302,7 @@
"enterSecretKey": "シークレットアクセスキーを入力", "enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL", "baseUrl": "ベース URL",
"optional": "(オプション)", "optional": "(オプション)",
"baseUrlWithExample": "ベース URLオプション、例: {example}",
"customEndpoint": "カスタムエンドポイント URL", "customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API推奨、または /v1 で OpenAI 互換 API を使用",
"models": "モデル", "models": "モデル",
"customModelId": "カスタムモデル ID...", "customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み", "allAdded": "すべて追加済み",

View File

@@ -1,354 +0,0 @@
{
"common": {
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"confirm": "確認",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中...",
"new": "新建"
},
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "開始新對話",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案圖片、PDF、文字",
"minimalStyle": "簡約",
"styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
"failedToCopy": "複製失敗",
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
"goodResponse": "有幫助",
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
},
"examples": {
"title": "用 AI 建立圖表",
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
"quickExamples": "快速範例",
"paperToDiagram": "文件轉圖表",
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
"animatedDiagram": "動畫圖表",
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
"awsArchitecture": "AWS 架構",
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
"replicateFlowchart": "複製流程圖",
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪圖",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例已快取,可即時回應",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
"preview": "預覽"
},
"settings": {
"title": "設定",
"description": "配置您的應用程式設定。",
"apiKeysModels": "API 金鑰和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 提供商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "提供商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL可選",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式:",
"switchTo": "切換到",
"minimal": "簡約",
"sketch": "草圖",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已啟用",
"disabled": "已停用",
"customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..."
},
"save": {
"title": "儲存圖表",
"description": "選擇格式和檔案名稱以儲存您的圖表。",
"format": "格式",
"filename": "檔案名稱",
"filenamePlaceholder": "輸入檔案名稱",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 圖片",
"svg": "SVG 圖片"
},
"savedSuccessfully": "儲存成功!"
},
"history": {
"title": "圖表歷史",
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
"version": "版本",
"restoreTo": "還原到版本 {version}"
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "清除所有內容",
"clearSuccess": "已開始新對話"
},
"errors": {
"maxFiles": "檔案太多。最多允許 {max} 個。",
"onlyMoreAllowed": "只能再新增 {slots} 個檔案",
"fileExceeds": "「{name}」大小為 {size}(超過 {max}MB",
"unsupportedType": "「{name}」不是支援的檔案類型",
"filesRejected": "{count} 個檔案被拒絕:",
"andMore": "...還有 {count} 個",
"invalidAccessCode": "無效或缺少存取碼。請在設定中配置。",
"networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
},
"quota": {
"dailyLimit": "已達每日配額",
"tokenLimit": "已達每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"messageApiSelfHosted": null,
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
"reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token包括豆包、DeepSeek 和 Kimi然後在模型設定中配置您的 API Key。",
"configModel": "使用您的金鑰",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
"reading": "讀取中...",
"chars": "字元",
"removeFile": "移除檔案"
},
"url": {
"title": "從 URL 擷取內容",
"description": "貼上 URL 以擷取和分析其內容",
"Extracting": "擷取中...",
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
"title": "開發XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "暫無聊天紀錄",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"justNow": "剛剛",
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
},
"validation": {
"title": "驗證圖表",
"capturing": "截圖中",
"validating": "驗證中",
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
"valid": "通過",
"validWithWarnings": "通過(有警告)",
"issuesFound": "發現問題",
"error": "錯誤",
"skipped": "已跳過",
"capturedScreenshot": "截圖預覽:",
"issuesFoundLabel": "發現的問題:",
"suggestions": "建議:",
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
"improveWithSuggestions": "根據建議改進",
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增提供商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除提供商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增",
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
"test": "測試",
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "新增提供商即可開始使用",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
"selectRegion": "選擇區域",
"apiKey": "API 金鑰",
"enterApiKey": "輸入您的 API 金鑰",
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"baseUrlWithExample": "基礎 URL可選例如 {example}",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API推薦或使用 /v1 端點為 OpenAI 相容 API",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",
"suggested": "推薦",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入提供商名稱...",
"modelsConfiguredCount": "已配置 {count} 個模型",
"validationFailedCount": "{count} 個模型驗證失敗",
"cancel": "取消",
"delete": "刪除",
"clickToChange": "(點擊變更)",
"usingServerDefault": "使用伺服器預設模型",
"selectModel": "選擇模型",
"searchModels": "搜尋模型...",
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
"noModelsFound": "未找到模型。",
"default": "預設",
"serverDefault": "伺服器預設",
"serverModels": "伺服器模型",
"userModels": "使用者模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型"
}
}

View File

@@ -29,17 +29,12 @@
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow",
"modelscope": "ModelScope", "modelscope": "ModelScope"
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
}, },
"chat": { "chat": {
"placeholder": "描述您的图表或上传文件...", "placeholder": "描述您的图表或上传文件...",
"send": "发送", "send": "发送",
"stopGeneration": "停止生成", "sending": "发送中...",
"sendMessage": "发送消息", "sendMessage": "发送消息",
"clearConversation": "清除对话", "clearConversation": "清除对话",
"diagramHistory": "图表历史", "diagramHistory": "图表历史",
@@ -82,8 +77,6 @@
"settings": { "settings": {
"title": "设置", "title": "设置",
"description": "配置您的应用程序设置。", "description": "配置您的应用程序设置。",
"apiKeysModels": "API 密钥和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
"accessCode": "访问码", "accessCode": "访问码",
"accessCodePlaceholder": "输入访问码", "accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。", "accessCodeDescription": "使用此应用程序需要访问码。",
@@ -126,10 +119,7 @@
"diagramValidation": "图表验证(实验性)", "diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。", "diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用", "enabled": "已启用",
"disabled": "已禁用", "disabled": "已禁用"
"customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..."
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -187,11 +177,8 @@
"tpmMessage": "请求过多。请稍等片刻。", "tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。", "tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageApiSelfHosted": null,
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。", "tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
"reset": "您的限制将在明天重置。感谢您的理解。", "reset": "您的限制将在明天重置。感谢您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token包括豆包、DeepSeek 和 Kimi然后在模型设置中配置您的 API Key。", "doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token包括豆包、DeepSeek 和 Kimi然后在模型设置中配置您的 API Key。",
"configModel": "使用您的密钥", "configModel": "使用您的密钥",
@@ -315,9 +302,7 @@
"enterSecretKey": "输入您的 Secret Key", "enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL", "baseUrl": "基础 URL",
"optional": "(可选)", "optional": "(可选)",
"baseUrlWithExample": "基础 URL可选例如 {example}",
"customEndpoint": "自定义端点 URL", "customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API推荐或使用 /v1 端点为 OpenAI 兼容 API",
"models": "模型", "models": "模型",
"customModelId": "自定义模型 ID...", "customModelId": "自定义模型 ID...",
"allAdded": "已全部添加", "allAdded": "已全部添加",

View File

@@ -14,12 +14,9 @@ export const ServerProviderSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
provider: ProviderNameSchema, provider: ProviderNameSchema,
models: z.array(z.string().min(1)), models: z.array(z.string().min(1)),
// Optional: custom environment variable name(s) for API key // Optional: custom environment variable name for API key
// Can be a single string or array of strings for load balancing // e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
// e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"] apiKeyEnv: z.string().min(1).optional(),
apiKeyEnv: z
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
.optional(),
// Optional: custom environment variable name for base URL // Optional: custom environment variable name for base URL
baseUrlEnv: z.string().min(1).optional(), baseUrlEnv: z.string().min(1).optional(),
// Optional: mark the first model in this provider as the default // Optional: mark the first model in this provider as the default
@@ -39,9 +36,8 @@ export interface FlattenedServerModel {
provider: ProviderName provider: ProviderName
providerLabel: string providerLabel: string
isDefault: boolean isDefault: boolean
// Custom env var name(s) for API key (optional) // Custom env var names for credentials (optional)
// Can be a single string or array of strings for load balancing apiKeyEnv?: string
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }

View File

@@ -47,33 +47,6 @@ interface ChatSessionDB extends DBSchema {
// Database singleton // Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
@@ -87,23 +60,7 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
} }
// Future migrations: if (oldVersion < 2) { ... } // Future migrations: if (oldVersion < 2) { ... }
}, },
terminated() {
resetDBPromise()
},
}) })
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
} }
return dbPromise return dbPromise
} }
@@ -118,46 +75,31 @@ export function isIndexedDBAvailable(): boolean {
} }
} }
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations // CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []
try { try {
return await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly") const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = [] const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML) // Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) { while (cursor) {
const s = cursor.value const s = cursor.value
metadata.push({ metadata.push({
id: s.id, id: s.id,
title: s.title, title: s.title,
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
messageCount: s.messages.length, messageCount: s.messages.length,
hasDiagram: hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
!!s.diagramXml && s.diagramXml.trim().length > 0, thumbnailDataUrl: s.thumbnailDataUrl,
thumbnailDataUrl: s.thumbnailDataUrl, })
}) cursor = await cursor.continue()
cursor = await cursor.continue() }
} return metadata
return metadata
})
} catch (error) { } catch (error) {
console.error("Failed to get session metadata:", error) console.error("Failed to get session metadata:", error)
return [] return []
@@ -167,9 +109,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> { export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null if (!isIndexedDBAvailable()) return null
try { try {
return await withDB(async (db) => { const db = await getDB()
return (await db.get(STORE_NAME, id)) || null return (await db.get(STORE_NAME, id)) || null
})
} catch (error) { } catch (error) {
console.error("Failed to get session:", error) console.error("Failed to get session:", error)
return null return null
@@ -179,9 +120,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (error) { } catch (error) {
// Handle quota exceeded // Handle quota exceeded
@@ -193,9 +133,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession() await deleteOldestSession()
// Retry once // Retry once
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (retryError) { } catch (retryError) {
console.error( console.error(
@@ -214,9 +153,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> { export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
await db.delete(STORE_NAME, id) await db.delete(STORE_NAME, id)
})
} catch (error) { } catch (error) {
console.error("Failed to delete session:", error) console.error("Failed to delete session:", error)
} }
@@ -225,9 +163,8 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> { export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0 if (!isIndexedDBAvailable()) return 0
try { try {
return await withDB(async (db) => { const db = await getDB()
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
})
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return 0 return 0
@@ -237,15 +174,14 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> { export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite") const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const cursor = await index.openCursor() const cursor = await index.openCursor()
if (cursor) { if (cursor) {
await cursor.delete() await cursor.delete()
} }
await tx.done await tx.done
})
} catch (error) { } catch (error) {
console.error("Failed to delete oldest session:", error) console.error("Failed to delete oldest session:", error)
} }

View File

@@ -27,7 +27,4 @@ export const STORAGE_KEYS = {
// Diagram validation // Diagram validation
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled", vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
// Custom system message
customSystemMessage: "next-ai-draw-io-custom-system-message",
} as const } as const

View File

@@ -11,7 +11,6 @@ export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications. Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload, and you can read the text content extracted from PDF documents they upload. You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
ALWAYS respond in the same language as the user's last message.
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML. When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it. After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
@@ -51,9 +50,9 @@ parameters: {
} }
---Tool4--- ---Tool4---
tool name: get_shape_library tool name: get_shape_library
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax. description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
parameters: { parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc. library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
} }
---End of tools--- ---End of tools---
@@ -61,7 +60,7 @@ IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty - Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items - Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped - Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram - Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
Core capabilities: Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams - Generate valid, well-formed XML strings for draw.io diagrams
@@ -92,7 +91,7 @@ Note that:
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity. - When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses. - Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square. - If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first. - For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns. - NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool: When using edit_diagram tool:

View File

@@ -16,12 +16,6 @@ export type ProviderName =
| "edgeone" | "edgeone"
| "doubao" | "doubao"
| "modelscope" | "modelscope"
| "glm"
| "qwen"
| "qiniu"
| "kimi"
| "minimax"
| "novita"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -79,67 +73,31 @@ export interface FlattenedModel {
source?: "user" | "server" source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var) // Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean isDefault?: boolean
// Custom env var name(s) for server models // Custom env var names for server models (allows multiple API keys per provider)
// Can be a single string or array of strings for load balancing apiKeyEnv?: string
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
// Map provider names to models.dev logo names
export const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
minimax: "minimax",
novita: "novita",
}
// Provider metadata // Provider metadata
export const PROVIDER_INFO: Record< export const PROVIDER_INFO: Record<
ProviderName, ProviderName,
{ label: string; defaultBaseUrl?: string } { label: string; defaultBaseUrl?: string }
> = { > = {
openai: { openai: { label: "OpenAI" },
label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1",
},
anthropic: { anthropic: {
label: "Anthropic", label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1", defaultBaseUrl: "https://api.anthropic.com/v1",
}, },
google: { google: { label: "Google" },
label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
vertexai: { label: "Google Vertex AI" }, vertexai: { label: "Google Vertex AI" },
azure: { azure: { label: "Azure OpenAI" },
label: "Azure OpenAI",
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
},
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: { ollama: {
label: "Ollama", label: "Ollama",
defaultBaseUrl: "https://ollama.com/api", defaultBaseUrl: "http://localhost:11434",
},
openrouter: {
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
}, },
openrouter: { label: "OpenRouter" },
deepseek: { label: "DeepSeek" },
siliconflow: { siliconflow: {
label: "SiliconFlow", label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1", defaultBaseUrl: "https://api.siliconflow.cn/v1",
@@ -148,10 +106,7 @@ export const PROVIDER_INFO: Record<
label: "SGLang", label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1", defaultBaseUrl: "http://127.0.0.1:8000/v1",
}, },
gateway: { gateway: { label: "AI Gateway" },
label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
},
edgeone: { label: "EdgeOne Pages" }, edgeone: { label: "EdgeOne Pages" },
doubao: { doubao: {
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",
@@ -161,30 +116,6 @@ export const PROVIDER_INFO: Record<
label: "ModelScope", label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1", defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
}, },
glm: {
label: "GLM (Zhipu)",
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
},
qwen: {
label: "Qwen (Alibaba)",
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
qiniu: {
label: "Qiniu",
defaultBaseUrl: "https://api.qnaigc.com/v1",
},
kimi: {
label: "Kimi (Moonshot)",
defaultBaseUrl: "https://api.moonshot.cn/v1",
},
minimax: {
label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
},
novita: {
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
},
} }
// Suggested models per provider for quick add // Suggested models per provider for quick add
@@ -314,7 +245,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen/Qwen2.5-Coder-32B-Instruct",
"Qwen/Qwen2.5-7B-Instruct", "Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct", "Qwen/Qwen2-VL-72B-Instruct",
"qwen3.5-plus",
], ],
sglang: [ sglang: [
// SGLang is OpenAI-compatible, models depend on deployment // SGLang is OpenAI-compatible, models depend on deployment
@@ -344,24 +274,10 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-32B", "Qwen/Qwen3-32B",
"qwen3.5-plus",
// DeepSeek // DeepSeek
"deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2", "deepseek-ai/DeepSeek-V3.2",
], ],
minimax: [
// MiniMax models (Anthropic-compatible API)
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
],
novita: [
// Novita AI models (OpenAI-compatible API)
"moonshotai/kimi-k2.5",
"zai-org/glm-5",
"minimax/minimax-m2.5",
],
} }
// Helper to generate UUID // Helper to generate UUID

View File

@@ -1,5 +1,4 @@
import { z } from "zod" import { z } from "zod"
import { getApiEndpoint } from "@/lib/base-path"
export interface UrlData { export interface UrlData {
url: string url: string
@@ -16,7 +15,7 @@ const UrlResponseSchema = z.object({
}) })
export async function extractUrlContent(url: string): Promise<UrlData> { export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch(getApiEndpoint("/api/parse-url"), { const response = await fetch("/api/parse-url", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }), body: JSON.stringify({ url }),

14728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.14", "version": "0.4.11",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -43,15 +43,15 @@
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18", "@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.8.0", "@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.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.17.1", "@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.212.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",
@@ -72,7 +72,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
@@ -108,7 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.4.4", "@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -128,8 +128,8 @@
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"eslint": "9.39.3", "eslint": "9.39.2",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
@@ -139,7 +139,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.60.0" "wrangler": "4.58.0"
}, },
"overrides": { "overrides": {
"@openrouter/ai-sdk-provider": { "@openrouter/ai-sdk-provider": {

View File

@@ -1,12 +1,12 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.17", "version": "0.1.12",
"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.17", "version": "0.1.12",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
@@ -469,9 +469,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.9", "version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -481,12 +481,12 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1", "version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.7",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -494,15 +494,14 @@
"cross-spawn": "^7.0.5", "cross-spawn": "^7.0.5",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0", "eventsource-parser": "^3.0.0",
"express": "^5.2.1", "express": "^5.0.1",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^7.5.0",
"hono": "^4.11.4", "jose": "^6.1.1",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2", "json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0", "pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0", "zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1" "zod-to-json-schema": "^3.25.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -521,9 +520,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.11.0", "version": "24.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1035,6 +1034,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -1074,13 +1074,10 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.2.1", "version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@@ -1263,10 +1260,11 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.9", "version": "4.11.1",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -1350,15 +1348,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2062,18 +2051,19 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
}, },
"node_modules/zod-to-json-schema": { "node_modules/zod-to-json-schema": {
"version": "3.25.1", "version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"zod": "^3.25 || ^4" "zod": "^3.25 || ^4"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.17", "version": "0.1.13",
"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",

View File

@@ -44,17 +44,6 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
return sessionId.startsWith("mcp-") && sessionId.length <= 128 return sessionId.startsWith("mcp-") && sessionId.length <= 128
} }
// Find the most recent active session (for auto-redirect when no sessionId provided)
function getMostRecentSessionId(): string | null {
let mostRecent: { id: string; lastUpdated: Date } | null = null
for (const [sessionId, state] of stateStore) {
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
}
}
return mostRecent?.id || null
}
function ensureSessionStateInitialized(sessionId: string): void { function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return if (!isLikelyMcpSessionId(sessionId)) return
@@ -69,8 +58,6 @@ interface SessionState {
lastUpdated: Date lastUpdated: Date
svg?: string // Cached SVG from last browser save svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
exportData?: string // Base64/SVG data returned by browser after export
} }
export const stateStore = new Map<string, SessionState>() export const stateStore = new Map<string, SessionState>()
@@ -93,8 +80,6 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
lastUpdated: new Date(), lastUpdated: new Date(),
svg: svg || existing?.svg, // Preserve cached SVG if not provided svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportData: existing?.exportData, // Preserve export result
}) })
log.debug(`State updated: session=${sessionId}, version=${newVersion}`) log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion return newVersion
@@ -198,12 +183,9 @@ function handleRequest(
): void { ): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`) const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
const requestOrigin = req.headers.origin res.setHeader("Access-Control-Allow-Origin", "*")
if (requestOrigin === `http://localhost:${serverPort}`) { res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Origin", requestOrigin) res.setHeader("Access-Control-Allow-Headers", "Content-Type")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
}
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
res.writeHead(204) res.writeHead(204)
@@ -213,17 +195,6 @@ function handleRequest(
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || "" const sessionId = url.searchParams.get("mcp") || ""
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
res.end()
return
}
}
ensureSessionStateInitialized(sessionId) ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
@@ -262,7 +233,6 @@ function handleStateApi(
xml: state?.xml || null, xml: state?.xml || null,
version: state?.version || 0, version: state?.version || 0,
syncRequested: !!state?.syncRequested, syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
@@ -272,30 +242,13 @@ function handleStateApi(
}) })
req.on("end", () => { req.on("end", () => {
try { try {
const data = JSON.parse(body) const { sessionId, xml, svg } = JSON.parse(body)
const { sessionId } = data
if (!sessionId) { if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" }) res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" })) res.end(JSON.stringify({ error: "sessionId required" }))
return return
} }
const version = setState(sessionId, xml, svg)
// Browser is returning export data (png/svg)
if (data.exportData !== undefined) {
const state = stateStore.get(sessionId)
if (state) {
state.exportData = data.exportData
state.exportFormat = undefined
log.debug(
`Export data received for session=${sessionId}`,
)
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
return
}
const version = setState(sessionId, data.xml, data.svg)
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version })) res.end(JSON.stringify({ success: true, version }))
} catch { } catch {
@@ -422,202 +375,85 @@ function getHtmlPage(sessionId: string): string {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Next AI Draw.io</title> <title>Draw.io MCP</title>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; } html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; } #container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header { #header {
padding: 0 20px; height: 52px; padding: 8px 16px; background: #1a1a2e; color: #eee;
background: linear-gradient(to bottom, #ffffff, #fafbfc); font-family: system-ui, sans-serif; font-size: 14px;
border-bottom: 1px solid #e8ecf0;
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative; z-index: 10;
}
#header .brand {
display: flex; align-items: center; gap: 10px;
}
#header .logo {
width: 28px; height: 28px; border-radius: 6px;
background: #18181b;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
#header .title {
font-size: 15px; font-weight: 600; color: #1a1a2e;
letter-spacing: -0.3px;
}
#header .session {
font-size: 11px; color: #8b95a5; font-weight: 400;
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
}
#header .right { display: flex; align-items: center; gap: 12px; }
#save-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: linear-gradient(to bottom, #18181b, #27272a);
color: white; border: none; cursor: pointer;
font-weight: 500; font-family: inherit;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
transition: all 0.15s ease;
}
#save-btn svg { width: 14px; height: 14px; }
#save-btn:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
}
#save-btn:active { transform: translateY(0); }
#save-btn:disabled, #history-btn:disabled {
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; transform: none; box-shadow: none;
}
#history-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
cursor: pointer; font-weight: 500; font-family: inherit;
transition: all 0.15s ease;
}
#history-btn svg { width: 14px; height: 14px; }
#history-btn:hover {
background: #e4e4e7; border-color: #d4d4d8;
} }
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; } #drawio { flex: 1; border: none; }
#history-modal, #save-modal { #history-btn {
display: none; position: fixed; inset: 0; position: fixed; bottom: 24px; right: 24px;
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); width: 48px; height: 48px; border-radius: 50%;
z-index: 2000; align-items: center; justify-content: center; background: #3b82f6; color: white; border: none; cursor: pointer;
} box-shadow: 0 4px 12px rgba(0,0,0,0.3);
#history-modal.open, #save-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 16px;
width: 90%; max-width: 480px; max-height: 70vh;
display: flex; flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
animation: modalIn 0.2s ease-out;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
}
.modal-header h2 {
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
letter-spacing: -0.3px;
}
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid #f1f3f5;
display: flex; gap: 10px; justify-content: flex-end;
}
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.history-item {
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
cursor: pointer; text-align: center; transition: all 0.15s ease;
background: #fafafa;
}
.history-item:hover { border-color: #a1a1aa; background: white; }
.history-item.selected {
border-color: #18181b; background: white;
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
}
.history-item .thumb {
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
margin-bottom: 6px; overflow: hidden; z-index: 1000;
}
#history-btn:hover { background: #2563eb; }
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
#history-btn svg { width: 24px; height: 24px; }
#history-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 2000;
align-items: center; justify-content: center;
}
#history-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 12px;
width: 90%; max-width: 500px; max-height: 70vh;
display: flex; flex-direction: column;
}
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
.modal-header h2 { font-size: 18px; margin: 0; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.history-item {
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
cursor: pointer; text-align: center;
}
.history-item:hover { border-color: #3b82f6; }
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
.history-item .thumb {
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px; overflow: hidden;
} }
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; } .history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; } .history-item .label { font-size: 12px; color: #666; }
.btn { .btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
padding: 9px 18px; border-radius: 8px; font-size: 13px; .btn-primary { background: #3b82f6; color: white; }
cursor: pointer; border: none; font-weight: 500; .btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
font-family: inherit; transition: all 0.15s ease; .btn-secondary { background: #f3f4f6; color: #374151; }
} .empty { text-align: center; padding: 40px; color: #666; }
.btn-primary {
background: linear-gradient(to bottom, #18181b, #27272a);
color: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
}
.btn-primary:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
}
.btn-primary:disabled {
background: #e4e4e7; color: #a1a1aa;
cursor: not-allowed; transform: none; box-shadow: none;
}
.btn-secondary {
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
}
.btn-secondary:hover { background: #e4e4e7; }
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block; font-size: 13px; font-weight: 500;
margin-bottom: 8px; color: #3f3f46;
}
.form-group select, .form-group input {
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
border-radius: 8px; font-size: 14px; outline: none;
font-family: inherit; background: white;
transition: all 0.15s ease;
}
.form-group select:focus, .form-group input:focus {
border-color: #18181b;
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
}
.filename-group { display: flex; }
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
.filename-group .ext {
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
font-family: 'SF Mono', Monaco, monospace;
}
</style> </style>
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<div id="header"> <div id="header">
<div class="brand"> <div>
<div class="logo"> <strong>Draw.io MCP</strong>
<svg viewBox="0 0 1536 1536" fill="#ffffff"> <span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
<g transform="translate(0,1536) scale(0.1,-0.1)">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>
</div>
<span class="title">Next AI Draw.io</span>
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
</div>
<div class="right">
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
History
</button>
<button id="save-btn" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download
</button>
</div> </div>
<div id="status" class="status disconnected">Connecting...</div>
</div> </div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe> <iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div> </div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div id="history-modal"> <div id="history-modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"><h2>History</h2></div> <div class="modal-header"><h2>History</h2></div>
@@ -631,39 +467,13 @@ function getHtmlPage(sessionId: string): string {
</div> </div>
</div> </div>
</div> </div>
<div id="save-modal">
<div class="modal-content">
<div class="modal-header"><h2>Download Diagram</h2></div>
<div class="modal-body">
<div class="form-group">
<label>Format</label>
<select id="save-format">
<option value="drawio">Draw.io (.drawio)</option>
<option value="png">PNG Image (.png)</option>
<option value="svg">SVG Vector (.svg)</option>
</select>
</div>
<div class="form-group">
<label>Filename</label>
<div class="filename-group">
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
<span class="ext" id="save-ext">.drawio</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
</div>
</div>
</div>
<script> <script>
const sessionId = "${sessionId}"; const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio'); const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null; let pendingSvgExport = null;
let pendingAiSvg = false; let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -671,6 +481,8 @@ function getHtmlPage(sessionId: string): string {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'init') { if (msg.event === 'init') {
isReady = true; isReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Request SVG export, then push state with SVG // Request SVG export, then push state with SVG
@@ -679,40 +491,6 @@ function getHtmlPage(sessionId: string): string {
// Fallback if export doesn't respond // Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000); setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) { } else if (msg.event === 'export' && msg.data) {
// Handle MCP server export request (png/svg)
// Verify the response matches the requested format to avoid capturing
// unrelated exports (autosave SVG, sync XML)
if (pendingMcpExport) {
const d = msg.data;
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
if (isPng || isSvg) {
pendingMcpExport = null;
fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d })
}).catch(() => {});
return;
}
}
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload;
pendingDownload = null;
let dataUrl = msg.data;
if (!dataUrl.startsWith('data:')) {
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
}
const a = document.createElement('a');
a.href = dataUrl; a.download = dl.filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
return;
}
// Handle sync export (XML format) - server requested fresh state // Handle sync export (XML format) - server requested fresh state
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) { if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
pendingSyncExport = false; pendingSyncExport = false;
@@ -775,84 +553,16 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true; pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*'); iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
} }
// Load new diagram from server (before export, so we export latest) // Load new diagram from server
if (s.version > currentVersion && s.xml) { if (s.version > currentVersion && s.xml) {
currentVersion = s.version; currentVersion = s.version;
loadDiagram(s.xml, true); loadDiagram(s.xml, true);
} }
// Handle export request from MCP server (png/svg) - after version update
if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat;
const exportOpts = s.exportFormat === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
// Timeout: reset if draw.io never responds
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
}
} catch {} } catch {}
} }
if (sessionId) { poll(); setInterval(poll, 2000); } if (sessionId) { poll(); setInterval(poll, 2000); }
// Save modal
const saveBtn = document.getElementById('save-btn');
const saveModal = document.getElementById('save-modal');
const saveFormat = document.getElementById('save-format');
const saveFilename = document.getElementById('save-filename');
const saveExt = document.getElementById('save-ext');
const saveCancelBtn = document.getElementById('save-cancel-btn');
const saveConfirmBtn = document.getElementById('save-confirm-btn');
let pendingDownload = null;
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
saveBtn.onclick = () => {
if (!sessionId || !isReady) return;
saveModal.classList.add('open');
saveFilename.focus();
saveFilename.select();
};
saveFormat.onchange = () => {
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
};
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
saveConfirmBtn.onclick = () => {
const format = saveFormat.value;
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
saveConfirmBtn.disabled = true;
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
}
const blob = new Blob([xmlData], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
} else if (format === 'png') {
pendingDownload = { format: 'png', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
} else if (format === 'svg') {
pendingDownload = { format: 'svg', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
}
};
// History UI // History UI
const historyBtn = document.getElementById('history-btn'); const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');

View File

@@ -546,24 +546,16 @@ server.registerTool(
server.registerTool( server.registerTool(
"export_diagram", "export_diagram",
{ {
description: description: "Export the current diagram to a .drawio file.",
"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " +
"The format is auto-detected from the file extension, or can be specified explicitly.",
inputSchema: { inputSchema: {
path: z path: z
.string() .string()
.describe( .describe(
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)", "File path to save the diagram (e.g., ./diagram.drawio)",
),
format: z
.enum(["drawio", "png", "svg"])
.optional()
.describe(
"Export format. If omitted, detected from file extension. Defaults to drawio.",
), ),
}, },
}, },
async ({ path, format }) => { async ({ path }) => {
try { try {
if (!currentSession) { if (!currentSession) {
return { return {
@@ -598,107 +590,21 @@ server.registerTool(
const fs = await import("node:fs/promises") const fs = await import("node:fs/promises")
const nodePath = await import("node:path") const nodePath = await import("node:path")
// Detect format from extension if not specified
const ext = nodePath.extname(path).toLowerCase()
const detectedFormat =
format ||
(ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio")
// Original .drawio export path (unchanged logic)
if (detectedFormat === "drawio") {
let filePath = path
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
}
}
// PNG or SVG: request browser to export via iframe
let filePath = path let filePath = path
if (ext !== `.${detectedFormat}`) { if (!filePath.endsWith(".drawio")) {
if (ext === ".drawio" || ext === ".png" || ext === ".svg") { filePath = `${filePath}.drawio`
filePath = filePath.slice(0, -ext.length)
}
filePath = `${filePath}.${detectedFormat}`
} }
const absolutePath = nodePath.resolve(filePath) const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
const state = getState(currentSession.id) log.info(`Diagram exported to ${absolutePath}`)
if (!state) {
return {
content: [
{
type: "text",
text: "Error: Session state not found. Is the browser open?",
},
],
isError: true,
}
}
state.exportFormat = detectedFormat as "png" | "svg"
state.exportData = undefined
// Wait for browser to produce the export data
const timeoutMs = 10000
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (state.exportData) break
await new Promise((r) => setTimeout(r, 200))
}
const exportData = state.exportData as string | undefined
state.exportData = undefined
state.exportFormat = undefined
if (!exportData) {
return {
content: [
{
type: "text",
text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.",
},
],
isError: true,
}
}
// Decode and write
if (detectedFormat === "png") {
const base64 = exportData.replace(
/^data:image\/png;base64,/,
"",
)
await fs.writeFile(absolutePath, Buffer.from(base64, "base64"))
} else {
let svgContent = exportData
if (svgContent.startsWith("data:image/svg+xml;base64,")) {
const base64 = svgContent.replace(
/^data:image\/svg\+xml;base64,/,
"",
)
svgContent = Buffer.from(base64, "base64").toString("utf-8")
}
await fs.writeFile(absolutePath, svgContent, "utf-8")
}
const stat = await fs.stat(absolutePath)
log.info(
`Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,
)
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`, text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
}, },
], ],
} }

View File

@@ -31,7 +31,6 @@ export function proxy(request: NextRequest) {
if ( if (
pathname.startsWith("/api/") || pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") || pathname.startsWith("/_next/") ||
pathname.startsWith("/drawio") ||
pathname.includes("/favicon") || pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname) /\.(.*)$/.test(pathname)
) { ) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

View File

@@ -1,7 +1,6 @@
/** /**
* electron-builder afterPack hook * electron-builder afterPack hook
* Copies node_modules to the standalone directory in the packaged app * Copies node_modules to the standalone directory in the packaged app
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
*/ */
const { const {
@@ -13,7 +12,6 @@ const {
statSync, statSync,
} = require("fs") } = require("fs")
const path = require("path") const path = require("path")
const { execSync } = require("child_process")
/** /**
* Copy directory recursively, converting symlinks to regular files/directories. * Copy directory recursively, converting symlinks to regular files/directories.
@@ -82,22 +80,4 @@ module.exports = async (context) => {
"Ensure 'npm run electron:prepare' was run before building.", "Ensure 'npm run electron:prepare' was run before building.",
) )
} }
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
if (context.packager.platform.name === "mac") {
const appPath = path.join(
appOutDir,
`${context.packager.appInfo.productFilename}.app`,
)
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
try {
execSync(`codesign --force --deep --sign - "${appPath}"`, {
stdio: "inherit",
})
console.log("[afterPack] Ad-hoc signing completed successfully")
} catch (error) {
console.error("[afterPack] Ad-hoc signing failed:", error.message)
throw error
}
}
} }

View File

@@ -1,6 +1,7 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams" import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import { import {
expect, expect,
expectBeforeAndAfterReload,
getChatInput, getChatInput,
getIframe, getIframe,
getIframeContent, getIframeContent,

View File

@@ -1,6 +1,7 @@
import { TEST_NODE_XML } from "./fixtures/diagrams" import { TEST_NODE_XML } from "./fixtures/diagrams"
import { import {
expect, expect,
getChatInput,
getIframe, getIframe,
getIframeContent, getIframeContent,
sendMessage, sendMessage,

View File

@@ -1,5 +1,6 @@
import { import {
expect, expect,
expectBeforeAndAfterReload,
getChatInput, getChatInput,
getIframe, getIframe,
openSettings, openSettings,

View File

@@ -3,6 +3,7 @@ import {
createMixedMock, createMixedMock,
createMultiTurnMock, createMultiTurnMock,
expect, expect,
getChatInput,
sendMessage, sendMessage,
test, test,
waitForComplete, waitForComplete,

View File

@@ -1,4 +1,10 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures" import {
expect,
getIframe,
getSettingsButton,
openSettings,
test,
} from "./lib/fixtures"
test.describe("Settings", () => { test.describe("Settings", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { describe, expect, it } from "vitest"
import { import {
getAIModel,
resolveBaseURL, resolveBaseURL,
supportsImageInput, supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
@@ -169,24 +168,6 @@ describe("supportsImageInput", () => {
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false) expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
}) })
it("returns true for Kimi K2.5 models (supports vision)", () => {
expect(supportsImageInput("kimi-k2.5")).toBe(true)
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
})
it("returns false for Moonshot v1 text models", () => {
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
})
it("returns false for MiniMax text models", () => {
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
expect(supportsImageInput("MiniMax-M2.5")).toBe(false)
expect(supportsImageInput("MiniMax-M2")).toBe(false)
expect(supportsImageInput("MiniMax-M2.5-highspeed")).toBe(false)
})
it("returns false for DeepSeek text models", () => { it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false) expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false) expect(supportsImageInput("deepseek-coder")).toBe(false)
@@ -195,30 +176,6 @@ describe("supportsImageInput", () => {
it("returns false for Qwen text models", () => { it("returns false for Qwen text models", () => {
expect(supportsImageInput("qwen-turbo")).toBe(false) expect(supportsImageInput("qwen-turbo")).toBe(false)
expect(supportsImageInput("qwen-plus")).toBe(false) expect(supportsImageInput("qwen-plus")).toBe(false)
expect(supportsImageInput("qwen3-max")).toBe(false)
})
it("returns true for Qwen vision models", () => {
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("qwen3.5-plus")).toBe(true)
expect(supportsImageInput("qwen3.5-flash")).toBe(true)
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
})
it("returns false for GLM text models", () => {
expect(supportsImageInput("glm-4")).toBe(false)
expect(supportsImageInput("glm-4-plus")).toBe(false)
expect(supportsImageInput("glm-4-flash")).toBe(false)
expect(supportsImageInput("glm-4-long")).toBe(false)
expect(supportsImageInput("glm-4.7")).toBe(false)
expect(supportsImageInput("glm-5")).toBe(false)
})
it("returns true for GLM vision models", () => {
expect(supportsImageInput("glm-4v")).toBe(true)
expect(supportsImageInput("glm-4v-9b")).toBe(true)
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
}) })
it("returns true for Claude and GPT models by default", () => { it("returns true for Claude and GPT models by default", () => {
@@ -227,120 +184,3 @@ describe("supportsImageInput", () => {
expect(supportsImageInput("gemini-pro")).toBe(true) expect(supportsImageInput("gemini-pro")).toBe(true)
}) })
}) })
vi.mock("ollama-ai-provider-v2", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateOllama = vi.fn(() => mockProviderFn)
const mockOllama = vi.fn(() => mockModel)
return { createOllama: mockCreateOllama, ollama: mockOllama }
})
describe("Ollama API key security", () => {
let createOllamaMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY
savedEnv.OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL
delete process.env.OLLAMA_BASE_URL
const mod = await import("ollama-ai-provider-v2")
createOllamaMock = mod.createOllama as ReturnType<typeof vi.fn>
createOllamaMock.mockClear()
})
afterEach(() => {
process.env.OLLAMA_API_KEY = savedEnv.OLLAMA_API_KEY
process.env.OLLAMA_BASE_URL = savedEnv.OLLAMA_BASE_URL
})
it("applies server OLLAMA_API_KEY when no client baseUrl is provided", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
headers: { Authorization: "Bearer server-secret-key" },
}),
)
})
it("does NOT leak server OLLAMA_API_KEY when client provides a custom baseUrl", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
// When server has OLLAMA_API_KEY, the SSRF guard rejects
// client-provided baseUrl without an apiKey outright
expect(() =>
getAIModel({
provider: "ollama",
baseUrl: "https://evil-server.com",
modelId: "llama2",
}),
).toThrow("API key is required")
})
it("uses client API key when client provides both baseUrl and apiKey", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
getAIModel({
provider: "ollama",
baseUrl: "https://my-ollama.com",
apiKey: "client-key",
modelId: "llama2",
})
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://my-ollama.com",
headers: { Authorization: "Bearer client-key" },
}),
)
})
it("applies both server OLLAMA_BASE_URL and OLLAMA_API_KEY when no client overrides", () => {
process.env.OLLAMA_BASE_URL = "https://cloud.ollama.com"
process.env.OLLAMA_API_KEY = "server-key"
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://cloud.ollama.com",
headers: { Authorization: "Bearer server-key" },
}),
)
})
it("works when OLLAMA_API_KEY is set but OLLAMA_BASE_URL is not", () => {
process.env.OLLAMA_API_KEY = "server-key"
delete process.env.OLLAMA_BASE_URL
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledTimes(1)
const callArgs = createOllamaMock.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("baseURL")
expect(callArgs).toEqual(
expect.objectContaining({
headers: { Authorization: "Bearer server-key" },
}),
)
})
it("allows client custom baseUrl without apiKey when no server OLLAMA_API_KEY", () => {
delete process.env.OLLAMA_API_KEY
getAIModel({
provider: "ollama",
baseUrl: "https://my-ollama.com",
modelId: "llama2",
})
expect(createOllamaMock).toHaveBeenCalledTimes(1)
const callArgs = createOllamaMock.mock.calls[0][0]
expect(callArgs.baseURL).toBe("https://my-ollama.com")
expect(callArgs).not.toHaveProperty("headers")
})
})

View File

@@ -45,72 +45,6 @@ describe("ServerModelsConfigSchema", () => {
ServerModelsConfigSchema.parse(invalidConfig as any), ServerModelsConfigSchema.parse(invalidConfig as any),
).toThrow() ).toThrow()
}) })
it("accepts apiKeyEnv as single string", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: "OPENAI_API_KEY_TEAM_A",
},
],
}
const parsed = ServerModelsConfigSchema.parse(config)
expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A")
})
it("accepts apiKeyEnv as array of strings for load balancing", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"],
},
],
}
const parsed = ServerModelsConfigSchema.parse(config)
expect(parsed.providers[0].apiKeyEnv).toEqual([
"OPENAI_KEY_1",
"OPENAI_KEY_2",
"OPENAI_KEY_3",
])
})
it("rejects empty array for apiKeyEnv", () => {
const config = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: [],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
})
it("rejects empty string in apiKeyEnv array", () => {
const config = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["VALID_KEY", ""],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
})
}) })
describe("loadFlattenedServerModels", () => { describe("loadFlattenedServerModels", () => {
@@ -148,24 +82,4 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.provider).toBe("openai") expect(defaultModel.provider).toBe("openai")
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
}) })
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI LoadBalanced",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"],
},
],
}
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(1)
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
})
}) })