Compare commits

..

55 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
2b5de8bbae Initial plan 2026-01-13 14:04:29 +00:00
dayuan.jiang
38a247c55d refactor: use EventAutoSave type for type safety 2026-01-13 23:02:19 +09:00
dayuan.jiang
a0e3130ac2 fix: enable autosave to sync user modifications in draw.io
Previously, user modifications made directly in draw.io (moving nodes,
editing text, etc.) were not captured by React state until an explicit
export was triggered.

This adds the autosave feature from react-drawio:
- Enable autosave={true} on DrawIoEmbed component
- Add onAutoSave callback to update chartXML on every change
- Keep React state in sync with draw.io editor state
2026-01-13 22:54:02 +09:00
Dayuan Jiang
6bd26c8bbd Merge pull request #578 from DayuanJiang/fix/user-apikey-baseurl-isolation
fix: prevent user API keys from using server's baseURL
2026-01-13 22:26:12 +09:00
dayuan.jiang
d2e51f159f refactor: use resolveBaseURL utility in all providers
Refactored all 11 providers to use the resolveBaseURL() utility function
instead of inline ternary expressions. This ensures:

1. The security fix is centralized in one testable function
2. Unit tests actually validate the production code path
3. Future changes only need to modify one location

Providers refactored: openai, anthropic, google, azure, openrouter,
deepseek, siliconflow, sglang, gateway, doubao, modelscope
2026-01-13 22:21:22 +09:00
dayuan.jiang
9677737745 test: add unit tests for baseURL isolation logic
Add comprehensive tests for the resolveBaseURL utility function:
- Tests for user-provided API key scenarios
- Tests for server credential scenarios
- Edge case tests for empty strings and undefined values

This addresses the Copilot review suggestion to add test coverage
for the critical security fix.
2026-01-13 22:14:45 +09:00
dayuan.jiang
d5774b336c fix: prevent user API keys from using server's baseURL
When users provide their own API key but not a custom baseURL,
the code was incorrectly falling back to the server's environment
variable for baseURL. This caused user API keys to be sent to
the server's custom proxy endpoint instead of the provider's
official endpoint, resulting in 'API key format incorrect' errors.

This fix ensures that when a user provides their own API key:
- Only the user's baseUrl is used (if provided)
- Otherwise, the provider's official/default endpoint is used
- Server's baseURL env vars are never mixed with user credentials

Affected providers: openai, anthropic, google, azure, openrouter,
deepseek, siliconflow, sglang, gateway, doubao, modelscope

Also fixes Azure's resourceName to not leak server config to user keys.

Fixes #577
2026-01-13 22:03:41 +09:00
Dayuan Jiang
0d79487b6c Merge pull request #573 from danqzq/enhance/remove-close-protection
enhance: remove close protection
2026-01-11 18:09:58 +09:00
danqzq
fbce1baf16 Remove Close Protection settings from language dictionaries 2026-01-10 22:47:03 -05:00
danqzq
c3d3afc202 Remove redundant Close Protection setting 2026-01-10 22:45:52 -05:00
Dayuan Jiang
651238529a Merge pull request #570 from DayuanJiang/chore/add-opencode-to-gitignore
chore: add opencode.json to gitignore
2026-01-11 11:05:45 +09:00
dayuan.jiang
35ab222343 chore: add opencode.json to gitignore 2026-01-11 11:02:34 +09:00
Maifee Ul Asad
b7eaf46555 [Feature] Add setting for Enter/Ctrl+Enter to send messages (#550)
* i18n: add translations for send shortcut setting

* feat: configurable keyboard shortcut for sending messages

* refactor,review: using storage key for send shortcut

* Increase the width of the trigger in the settings dialog. Previously, at 160px, it hide the letter “d” from the word “Send.”

* Update components/chat-input.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: from review, ctrl send support for mac

* refactor: from review, reduce local storage read

* fix: make send shortcut setting reactive without page refresh

---------

Co-authored-by: Biki Kalita <86558912+Biki-dev@users.noreply.github.com>
Co-authored-by: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-11 10:54:32 +09:00
Dayuan Jiang
5eb797b191 chore: reduce Renovate noise - monthly schedule, group major updates (#569) 2026-01-10 23:31:01 +09:00
renovate[bot]
bc0f96d3c9 fix(deps): update dependency nanoid to v5 (#568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-10 23:24:40 +09:00
renovate[bot]
f8f197db7b chore(deps): update dependency @types/react to v19.2.8 (#564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-10 23:24:32 +09:00
Dayuan Jiang
518b72a0be Merge pull request #562 from DayuanJiang/renovate/core-framework-packages
chore(deps): update core framework packages
2026-01-10 19:28:50 +09:00
Dayuan Jiang
5e503551e6 Merge pull request #563 from DayuanJiang/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v6
2026-01-10 19:28:34 +09:00
renovate[bot]
09d478f6b2 chore(deps): update actions/upload-artifact action to v6 2026-01-10 09:34:44 +00:00
renovate[bot]
1860233923 chore(deps): update core framework packages 2026-01-10 09:34:40 +00:00
Dayuan Jiang
7bdb43cd20 Merge pull request #558 from DayuanJiang/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-01-10 14:42:33 +09:00
Dayuan Jiang
3d816442f6 Merge pull request #559 from DayuanJiang/renovate/actions-setup-node-6.x
chore(deps): update actions/setup-node action to v6
2026-01-10 14:42:26 +09:00
Dayuan Jiang
f59a2f2b01 Merge pull request #556 from DayuanJiang/renovate/minor-and-patch-dependencies
fix(deps): update minor and patch dependencies
2026-01-10 14:40:03 +09:00
Dayuan Jiang
17b765bfd9 Merge pull request #552 from DayuanJiang/renovate/core-framework-packages
fix(deps): update core framework packages
2026-01-10 14:39:59 +09:00
renovate[bot]
555a21033b fix(deps): update minor and patch dependencies 2026-01-10 05:39:03 +00:00
renovate[bot]
ef6517b89a fix(deps): update core framework packages 2026-01-10 05:37:05 +00:00
Dayuan Jiang
945dffc949 Merge pull request #441 from DayuanJiang/renovate/zod-4.x
fix(deps): update dependency zod to v4
2026-01-10 14:36:20 +09:00
Dayuan Jiang
0b41084c24 Merge pull request #553 from DayuanJiang/renovate/electron-packages
chore(deps): update dependency electron-builder to v26.4.0
2026-01-10 14:36:09 +09:00
Dayuan Jiang
860eabd593 Merge pull request #557 from DayuanJiang/renovate/actions-cache-5.x
chore(deps): update actions/cache action to v5
2026-01-10 14:36:02 +09:00
renovate[bot]
4ec901e713 chore(deps): update actions/setup-node action to v6 2026-01-10 05:08:04 +00:00
renovate[bot]
3959e909c4 chore(deps): update actions/checkout action to v6 2026-01-10 05:07:54 +00:00
Dayuan Jiang
4a0973a373 chore: remove dead code and consolidate duplicate types (#555)
- Delete unused files: lib/ai-config.ts, components/ui/card.tsx, lib/token-counter.ts
- Remove js-tiktoken dependency (only used by deleted token-counter.ts)
- Consolidate ProviderName type: add "ollama" to model-config.ts, import in ai-providers.ts
- Consolidate DiagramOperation type: keep in chat/types.ts, import in utils.ts and hook
2026-01-10 14:06:17 +09:00
renovate[bot]
cb09f8c74e chore(deps): update actions/cache action to v5 2026-01-10 04:58:14 +00:00
renovate[bot]
53dbd5320b chore(deps): update dependency electron-builder to v26.4.0 2026-01-10 01:40:17 +00:00
Vishakha Agrawal
32d1361ffa Modernize Input Field Scrollbar Design (#536) (#538)
* Modernize Input Field Scrollbar Design #536

* Added Input Field Scrollbar Design #536

* The edit mode for the user scroller already looks good, so there’s no need to change it. The scrollbar-thin class only makes the scrollbar smaller compared to when it’s not present, so it isn’t needed.

---------

Co-authored-by: Biki Kalita <86558912+Biki-dev@users.noreply.github.com>
2026-01-09 20:50:02 +09:00
Rank Preet
4cf9661adb Fix #525: Copy public folder in Electron build to include favicon-white.svg (#545) 2026-01-09 14:10:44 +09:00
Dayuan Jiang
9430618660 docs: fix FAQ formatting and update model recommendations (#546)
- Add missing "Problem" statement to FAQ #4 for consistency
- Update vision model recommendations to latest versions (GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro)
2026-01-09 13:43:26 +09:00
Dayuan Jiang
d71fe70cbe docs: add FAQ documentation for common issues (#544)
- Add FAQ.md in English, Chinese, and Japanese
- Link FAQ from each language README
- Cover: PDF export, offline deployment, self-hosted models, image upload
2026-01-09 13:30:07 +09:00
Dayuan Jiang
22f4c2e270 fix: update SiliconFlow default endpoint to .cn (#543)
SiliconFlow is transitioning from .com to .cn domain. The .cn endpoint
uses Global Traffic Manager (GTM) for better global access, while .com
is being phased out.
2026-01-09 13:21:34 +09:00
Dayuan Jiang
73f282e568 feat: add Claude Code plugin package (#541)
Add separate plugin package for Claude Code plugin directory submission.

Structure:
- .claude-plugin/plugin.json - plugin metadata
- .mcp.json - MCP server configuration
- README.md - documentation with use case examples
2026-01-09 11:37:46 +09:00
Dayuan Jiang
085d656a3c chore: bump version to 0.4.10 (#540) 2026-01-09 10:41:35 +09:00
Dayuan Jiang
d22474b541 feat: add proxy settings to Settings dialog (Desktop only) (#537)
* feat: add proxy settings to Settings dialog (Desktop only)

Fixes #535 - Desktop app now respects HTTP/HTTPS proxy configuration.

- Add proxy-manager.ts to handle proxy config storage (JSON file in userData)
- Load proxy settings on app startup before Next.js server starts
- Add IPC handlers for get-proxy and set-proxy
- Add proxy settings UI in Settings dialog (Electron only)
- Add translations for en/zh/ja

* fix: improve proxy settings reliability and simplify UI

- Fix server restart race condition (wait for process exit before starting new server)
- Add URL validation (must include http:// or https:// prefix)
- Enable Node.js built-in proxy support (NODE_USE_ENV_PROXY=1)
- Remove "Proxy Exceptions" field (unnecessary for this app)
- Add debug logging for proxy env vars

* refactor: remove duplicate ProxyConfig interface, import from electron.d.ts
2026-01-09 09:26:19 +09:00
renovate[bot]
53a2b8a0be fix(deps): update dependency zod to v4 2026-01-08 16:32:43 +00:00
Dayuan Jiang
083c2a4142 fix: specify artifact-configuration-slug for SignPath (#533) 2026-01-08 12:52:24 +09:00
Dayuan Jiang
c4b1ec8d28 feat: add SignPath code signing for Windows builds (#531)
- Split workflow into mac/linux and windows jobs
- Add dist:win:build script with --publish never
- Integrate SignPath signing for Windows executables
- Sign both NSIS installer and portable EXE files
2026-01-08 10:51:12 +09:00
Dayuan Jiang
6ad4a9b303 chore(mcp-server): fix author and repository to DayuanJiang (#529) 2026-01-07 12:30:14 +09:00
broBinChen
dcf222114c fix: add missing nanoid dependency (#528) 2026-01-07 12:06:05 +09:00
Biki Kalita
4ece615548 fix - not clearing the loading state (#524) 2026-01-07 08:30:12 +09:00
yrk111222
54fd48506d Feat/add modelscope support (#521)
* add ModelScope API support

* update some documentation

* modify some details
2026-01-06 19:41:25 +09:00
zhoujie0531
ffcb241383 feat: mod readme (#522)
Co-authored-by: zoejiezhou <zoejiezhou@tencent.com>
2026-01-06 17:57:40 +09:00
Dayuan Jiang
79491e2143 chore: remove usage limits from about pages (#520) 2026-01-06 10:46:13 +09:00
Biki Kalita
6326f9dec6 🔗 Add URL Content Extraction Feature (#514)
* feat: add URL content extraction for AI diagram generation

* Changes made as recommended by Claude:

1. Added a request timeout to prevent server resources from being tied up (route.ts)
2. Implemented runtime validation for the API response shape (url-utils.ts)
3. Removed hardcoded English error messages and replaced them with localized strings (url-input-dialog.tsx)
4. Fixed the incorrect i18n namespace (changed from pdf.* to url.*) (url-input-dialog.tsx and en/ja/zh.json)

* chore: restore package.json and package-lock.json

* fix: use i18n strings for URL dialog error messages

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-06 00:23:50 +09:00
Dayuan Jiang
625d8f2afe fix: use OpenAI provider for Doubao multimodal models (#519)
DeepSeek provider was not properly formatting image content for Doubao's
API. Now uses OpenAI provider for Doubao models (multimodal support),
while keeping DeepSeek provider for DeepSeek/Kimi models on the platform.
2026-01-05 23:09:09 +09:00
Dayuan Jiang
0026639ee8 fix: add NEXT_PUBLIC_BASE_PATH build arg for subdirectory deployment (#518)
Dockerfile was missing the ARG declaration to receive NEXT_PUBLIC_BASE_PATH
from docker-compose build args, causing subdirectory deployment to fail.

Fixes #478
2026-01-05 21:40:15 +09:00
Dayuan Jiang
c7a85d398f test: add Vitest and Playwright testing infrastructure (#512)
* test: add Vitest and Playwright testing infrastructure

- Add Vitest for unit tests (39 tests)
  - cached-responses.test.ts
  - ai-providers.test.ts
  - chat-helpers.test.ts
  - utils.test.ts
- Add Playwright for E2E tests (3 smoke tests)
  - Homepage load
  - Japanese locale
  - Settings dialog
- Add CI workflow (.github/workflows/test.yml)
- Add vitest.config.mts and playwright.config.ts
- Update .gitignore for test artifacts

* test: add more E2E tests for UI components

- Chat panel tests (interactive elements, iframe)
- Settings tests (dark mode, language, draw.io theme)
- Save dialog tests (buttons exist)
- History dialog tests
- Model config tests
- Keyboard interaction tests
- Upload area tests

Total: 15 E2E tests, all passing

* test: fix E2E test issues from review

Fixes based on Gemini and Codex review:
- Remove brittle nth(1) selector in keyboard tests
- Remove waitForTimeout(500) race condition
- Remove if(isVisible) silent skip patterns
- Add proper assertions instead of no-op checks
- Remove expect(count >= 0) that always passes
- Remove unused hasProviderUI variable

All 14 E2E tests and 39 unit tests pass.

* style: auto-format with Biome

* fix: resolve lint errors for CI

* test(e2e): add diagram generation tests with mocked AI responses

- Add tests for generate, edit, and append diagram operations
- Use SSE mocked responses matching AI SDK UI message stream format
- Generate mxCell XML directly in tests for deterministic assertions
- Tests verify tool card rendering and 'Complete' badge state

* test: add comprehensive E2E tests for all major features

- Error handling tests (API errors, rate limits, network timeout, truncated XML)
- Multi-turn conversation tests (sequential requests, history preservation)
- File upload tests (upload button, file preview, sending with message)
- Theme switching tests (dark mode toggle, persistence, system preference)
- Language switching tests (EN/JA/ZH, persistence, locale URLs)
- Iframe interaction tests (draw.io loading, toolbar, diagram rendering)
- Copy/paste tests (chat input, XML input, special characters)
- History restore tests (new chat, persistence, browser navigation)

* refactor: extract shared test helpers and improve error assertions

- Create tests/e2e/lib/helpers.ts with shared SSE mock functions
- Add proper error UI assertions to error-handling.spec.ts
- Remove waitForTimeout calls in favor of real assertions
- Update 6 test files to use shared helpers

* docs: add testing section to CONTRIBUTING.md

* fix: improve test infrastructure based on PR review

- Fix double build in CI: remove redundant build from playwright webServer
- Export chat helpers from shared module for proper unit testing
- Replace waitForTimeout with explicit waits in E2E tests
- Add data-testid attributes to settings and new chat buttons
- Add list reporter for CI to show failures in logs
- Add Playwright browser caching to speed up CI
- Add vitest coverage configuration
- Fix conditional test assertions to use test.skip() instead of silent pass
- Remove unused variables flagged by linter

* fix: improve E2E test assertions and remove silent skips

- Replace silent test.skip() with explicit conditional skips
- Add actual persistence assertion after page reload
- Use data-testid selector for new chat button test

* refactor: add shared fixtures and test.step() patterns

- Add tests/e2e/lib/fixtures.ts with shared test helpers
- Add tests/e2e/fixtures/diagrams.ts with XML test data
- Add expectBeforeAndAfterReload() helper for persistence tests
- Add test.step() for better test reporting in complex tests
- Consolidate mock helpers into fixtures module
- Reduce code duplication across 17 test files

* fix: make persistence tests more reliable

- Remove expectBeforeAndAfterReload from mocked API tests
- Add explicit test.step() for before/after reload checks
- Add retry config for flaky clipboard tests
- Add sleep after reload for language persistence test

* test: remove flaky XML paste test

* docs: run both unit and e2e tests before PR

* chore: add type check and unit test git hooks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 01:37:32 +09:00
62 changed files with 4538 additions and 1671 deletions

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"schedule": ["after 10am on saturday"], "schedule": ["after 10am on the first day of the month"],
"timezone": "Asia/Tokyo", "timezone": "Asia/Tokyo",
"packageRules": [ "packageRules": [
{ {
@@ -13,6 +13,7 @@
{ {
"matchUpdateTypes": ["major"], "matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"], "matchPackagePatterns": ["*"],
"groupName": "major dependencies",
"automerge": false "automerge": false
}, },
{ {

View File

@@ -11,7 +11,8 @@ on:
required: false required: false
jobs: jobs:
build: # Mac and Linux: Build and publish directly (no signing needed)
build-mac-linux:
permissions: permissions:
contents: write contents: write
strategy: strategy:
@@ -20,13 +21,9 @@ jobs:
include: include:
- os: macos-latest - os: macos-latest
platform: mac platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest - os: ubuntu-latest
platform: linux platform: linux
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -40,7 +37,58 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Build and publish Electron app - name: Build and publish
run: npm run dist:${{ matrix.platform }} run: npm run dist:${{ matrix.platform }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Windows: Build, sign with SignPath, then publish
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "npm"
- name: Install dependencies
run: npm install
# Build WITHOUT publishing
- name: Build Windows app
run: npm run dist:win:build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload unsigned artifacts for signing
uses: actions/upload-artifact@v6
id: upload-unsigned
with:
name: windows-unsigned
path: release/*.exe
retention-days: 1
- name: Sign with SignPath
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: release-signed
- name: Upload signed artifacts to release
uses: softprops/action-gh-release@v2
with:
files: release-signed/*.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -11,10 +11,10 @@ jobs:
name: Lint & Unit Tests name: Lint & Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "20"
cache: "npm" cache: "npm"
@@ -32,10 +32,10 @@ jobs:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "20"
cache: "npm" cache: "npm"
@@ -44,7 +44,7 @@ jobs:
run: npm ci run: npm ci
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v4 uses: actions/cache@v5
id: playwright-cache id: playwright-cache
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
@@ -67,7 +67,7 @@ jobs:
CI: true CI: true
- name: Upload test results - name: Upload test results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
if: always() if: always()
with: with:
name: playwright-report name: playwright-report

3
.gitignore vendored
View File

@@ -67,4 +67,5 @@ CLAUDE.md
.spec-workflow .spec-workflow
# edgeone # edgeone
.edgeone .edgeone
opencode.json

View File

@@ -30,6 +30,10 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE} ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Build Next.js application (standalone mode) # Build Next.js application (standalone mode)
RUN npm run build RUN npm run build

View File

@@ -40,11 +40,12 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Installation](#installation) - [Installation](#installation)
- [Deployment](#deployment) - [Deployment](#deployment)
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages) - [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended) - [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support) - [Multi-Provider Support](#multi-provider-support)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Support \& Contact](#support--contact) - [Support \& Contact](#support--contact)
- [FAQ](#faq)
- [Star History](#star-history) - [Star History](#star-history)
## Examples ## Examples
@@ -185,7 +186,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai). Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
### Deploy on Vercel (Recommended) ### Deploy on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -211,6 +212,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang - SGLang
- Vercel AI Gateway - Vercel AI Gateway
@@ -245,6 +247,10 @@ For support or inquiries, please open an issue on the GitHub repository or conta
- Email: me[at]jiang.jp - Email: me[at]jiang.jp
## FAQ
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -10,18 +10,7 @@ export const metadata: Metadata = {
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutCN() { export default function AboutCN() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -108,42 +97,6 @@ export default function AboutCN() {
</p> </p>
</div> </div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
使
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
Token/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
Token/
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -344,6 +297,7 @@ export default function AboutCN() {
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "} <code>claude-sonnet-4-5</code>{" "}

View File

@@ -17,18 +17,7 @@ export const metadata: Metadata = {
], ],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutJA() { export default function AboutJA() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -116,42 +105,6 @@ export default function AboutJA() {
</p> </p>
</div> </div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
使
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -359,6 +312,7 @@ export default function AboutJA() {
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code> <code>claude-sonnet-4-5</code>

View File

@@ -17,18 +17,7 @@ export const metadata: Metadata = {
], ],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function About() { export default function About() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -118,42 +107,6 @@ export default function About() {
</p> </p>
</div> </div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
Please note the current usage limits:
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
requests/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/min
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -378,6 +331,7 @@ export default function About() {
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on Note that <code>claude-sonnet-4-5</code> has trained on

View File

@@ -4,7 +4,6 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -17,8 +16,13 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
handleAutoSave,
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")
@@ -29,7 +33,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 [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false) const isMobileRef = useRef(false)
@@ -66,13 +69,6 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark) document.documentElement.classList.toggle("dark", prefersDark)
} }
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
setCloseProtection(true)
}
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -146,20 +142,6 @@ export default function Home() {
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown)
}, []) }, [])
// Show confirmation dialog when user tries to leave the page
useEffect(() => {
if (!closeProtection) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
return ""
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () =>
window.removeEventListener("beforeunload", handleBeforeUnload)
}, [closeProtection])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
@@ -187,6 +169,8 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
onAutoSave={handleAutoSave}
autosave={true}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
@@ -243,7 +227,6 @@ export default function Home() {
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange} onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/> />
</Suspense> </Suspense>
</div> </div>

154
app/api/parse-url/route.ts Normal file
View File

@@ -0,0 +1,154 @@
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000
// SSRF protection - block private/internal addresses
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
return true // Invalid URL - block it
}
}
export async function POST(req: Request) {
try {
const { url } = await req.json()
if (!url || typeof url !== "string") {
return NextResponse.json(
{ error: "URL is required" },
{ status: 400 },
)
}
// Validate URL format
try {
new URL(url)
} catch {
return NextResponse.json(
{ error: "Invalid URL format" },
{ status: 400 },
)
}
// SSRF protection
if (isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let article
try {
article = await extract(url, undefined, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal,
})
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
{ error: "Timed out while fetching URL content" },
{ status: 504 },
)
}
throw err
} finally {
clearTimeout(timeoutId)
}
if (!article || !article.content) {
return NextResponse.json(
{ error: "Could not extract content from URL" },
{ status: 400 },
)
}
// Convert HTML to Markdown
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
})
// Remove unwanted elements before conversion
turndownService.remove(["script", "style", "iframe", "noscript"])
const markdown = turndownService.turndown(article.content)
// Check content length
if (markdown.length > MAX_CONTENT_LENGTH) {
return NextResponse.json(
{
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
},
{ status: 400 },
)
}
return NextResponse.json({
title: article.title || "Untitled",
content: markdown,
charCount: markdown.length,
})
} catch (error) {
console.error("URL extraction error:", error)
return NextResponse.json(
{ error: "Failed to fetch or parse URL content" },
{ status: 500 },
)
}
}

View File

@@ -202,7 +202,7 @@ export async function POST(req: Request) {
case "siliconflow": { case "siliconflow": {
const sf = createOpenAI({ const sf = createOpenAI({
apiKey, apiKey,
baseURL: baseUrl || "https://api.siliconflow.com/v1", baseURL: baseUrl || "https://api.siliconflow.cn/v1",
}) })
model = sf.chat(modelId) model = sf.chat(modelId)
break break
@@ -251,16 +251,98 @@ export async function POST(req: Request) {
} }
case "doubao": { case "doubao": {
// ByteDance Doubao uses DeepSeek-compatible API // ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubao = createDeepSeek({ const doubaoBaseUrl =
apiKey, baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
baseURL: const lowerModelId = modelId.toLowerCase()
baseUrl || "https://ark.cn-beijing.volces.com/api/v3", if (
}) lowerModelId.includes("deepseek") ||
model = doubao(modelId) lowerModelId.includes("kimi")
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break break
} }
case "modelscope": {
const baseURL =
baseUrl || "https://api-inference.modelscope.cn/v1"
const startTime = Date.now()
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
`${baseURL}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: "user", content: "Say 'OK'" },
],
max_tokens: 20,
stream: true,
enable_thinking: false,
}),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
)
}
const contentType =
response.headers.get("content-type") || ""
const isValidStreamingResponse =
response.status === 200 &&
(contentType.includes("text/event-stream") ||
contentType.includes("application/json"))
if (!isValidStreamingResponse) {
throw new Error(
`Unexpected response format: ${contentType}`,
)
}
const responseTime = Date.now() - startTime
if (response.body) {
response.body.cancel().catch(() => {
/* Ignore cancellation errors */
})
}
return NextResponse.json({
valid: true,
responseTime,
note: "ModelScope model validated (using streaming API)",
})
} catch (error) {
console.error(
"[validate-model] ModelScope validation failed:",
error,
)
throw error
}
}
default: default:
return NextResponse.json( return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` }, { valid: false, error: `Unknown provider: ${provider}` },

View File

@@ -244,6 +244,19 @@
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260); background-color: oklch(0.75 0.01 260);
} }
/* Dark mode scrollbar */
.dark .scrollbar-thin {
scrollbar-color: oklch(0.35 0.015 260) transparent;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.35 0.015 260);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.45 0.015 260);
}
} }
/* Smooth page transitions */ /* Smooth page transitions */

View File

@@ -4,6 +4,7 @@ import {
Download, Download,
History, History,
Image as ImageIcon, Image as ImageIcon,
Link,
Loader2, Loader2,
Send, Send,
} from "lucide-react" } from "lucide-react"
@@ -18,11 +19,14 @@ import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config" import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { FilePreviewList } from "./file-preview-list" import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -144,6 +148,8 @@ interface ChatInputProps {
File, File,
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string sessionId?: string
error?: Error | null error?: Error | null
@@ -163,6 +169,8 @@ export function ChatInput({
files = [], files = [],
onFileChange = () => {}, onFileChange = () => {},
pdfData = new Map(), pdfData = new Map(),
urlData,
onUrlChange,
sessionId, sessionId,
error = null, error = null,
models = [], models = [],
@@ -183,6 +191,9 @@ export function ChatInput({
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
(status === "streaming" || status === "submitted") && !error (status === "streaming" || status === "submitted") && !error
@@ -199,13 +210,36 @@ export function ChatInput({
adjustTextareaHeight() adjustTextareaHeight()
}, [input, adjustTextareaHeight]) }, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e) onChange(e)
adjustTextareaHeight() adjustTextareaHeight()
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget.closest("form") const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
@@ -312,6 +346,50 @@ export function ChatInput({
} }
} }
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -324,13 +402,23 @@ export function ChatInput({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
> >
{/* File previews */} {/* File & URL previews */}
{files.length > 0 && ( {(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3"> <div className="mb-3">
<FilePreviewList <FilePreviewList
files={files} files={files}
onRemoveFile={handleRemoveFile} onRemoveFile={handleRemoveFile}
pdfData={pdfData} pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/> />
</div> </div>
)} )}
@@ -344,7 +432,7 @@ export function ChatInput({
placeholder={dict.chat.placeholder} placeholder={dict.chat.placeholder}
disabled={isDisabled} disabled={isDisabled}
aria-label="Chat input" aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60" className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/> />
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50"> <div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
@@ -385,6 +473,20 @@ export function ChatInput({
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -443,6 +545,14 @@ export function ChatInput({
.toISOString() .toISOString()
.slice(0, 10)}`} .slice(0, 10)}`}
/> />
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form> </form>
) )
} }

View File

@@ -1114,7 +1114,7 @@ export function ChatMessageDisplay({
)} )}
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30"> <div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
<pre className="text-xs whitespace-pre-wrap text-foreground/80"> <pre className="text-xs whitespace-pre-wrap text-foreground/80">
{ {
section.content section.content

View File

@@ -34,6 +34,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage" import { sanitizeMessages } from "@/lib/session-storage"
import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils" import { cn, formatXML, isRealDiagram } from "@/lib/utils"
@@ -69,7 +70,6 @@ interface ChatPanelProps {
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
isMobile?: boolean isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
} }
// Constants for tool states // Constants for tool states
@@ -110,7 +110,6 @@ export default function ChatPanel({
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
isMobile = false, isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -158,6 +157,7 @@ export default function ChatPanel({
// File processing using extracted hook // File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor() const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())
const [showSettingsDialog, setShowSettingsDialog] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false) const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -710,6 +710,8 @@ export default function ChatPanel({
input, input,
files, files,
pdfData, pdfData,
undefined,
urlData,
) )
setMessages([ setMessages([
@@ -735,6 +737,7 @@ export default function ChatPanel({
setInput("") setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([]) setFiles([])
setUrlData(new Map())
return return
} }
} }
@@ -755,6 +758,7 @@ export default function ChatPanel({
files, files,
pdfData, pdfData,
parts, parts,
urlData,
) )
// Add the combined text as the first part // Add the combined text as the first part
@@ -779,6 +783,7 @@ export default function ChatPanel({
setInput("") setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([]) setFiles([])
setUrlData(new Map())
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error) console.error("Error fetching chart data:", error)
} }
@@ -854,6 +859,7 @@ export default function ChatPanel({
clearDiagram() clearDiagram()
setDiagramHistory([]) setDiagramHistory([])
handleFileChange([]) // Use handleFileChange to also clear pdfData handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random() const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36) .toString(36)
.slice(2, 9)}` .slice(2, 9)}`
@@ -972,6 +978,7 @@ export default function ChatPanel({
files: File[], files: File[],
pdfData: Map<File, FileData>, pdfData: Map<File, FileData>,
imageParts?: any[], imageParts?: any[],
urlDataParam?: Map<string, UrlData>,
): Promise<string> => { ): Promise<string> => {
let userText = baseText let userText = baseText
@@ -1002,6 +1009,14 @@ export default function ChatPanel({
} }
} }
if (urlDataParam) {
for (const [url, data] of urlDataParam) {
if (data.content) {
userText += `\n\n[URL: ${url}]\nTitle: ${data.title}\n\n${data.content}`
}
}
}
return userText return userText
} }
@@ -1264,6 +1279,8 @@ export default function ChatPanel({
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
pdfData={pdfData} pdfData={pdfData}
urlData={urlData}
onUrlChange={setUrlData}
sessionId={sessionId} sessionId={sessionId}
error={error} error={error}
models={modelConfig.models} models={modelConfig.models}
@@ -1277,7 +1294,6 @@ export default function ChatPanel({
<SettingsDialog <SettingsDialog
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi} onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode} darkMode={darkMode}

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { FileCode, FileText, Loader2, X } from "lucide-react" import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
@@ -20,12 +20,19 @@ interface FilePreviewListProps {
File, File,
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
urlData?: Map<
string,
{ url: string; title: string; charCount: number; isExtracting: boolean }
>
onRemoveUrl?: (url: string) => void
} }
export function FilePreviewList({ export function FilePreviewList({
files, files,
onRemoveFile, onRemoveFile,
pdfData = new Map(), pdfData = new Map(),
urlData,
onRemoveUrl,
}: FilePreviewListProps) { }: FilePreviewListProps) {
const dict = useDictionary() const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null) const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -77,7 +84,7 @@ export function FilePreviewList({
} }
}, [imageUrls, selectedImage]) }, [imageUrls, selectedImage])
if (files.length === 0) return null if (files.length === 0 && (!urlData || urlData.size === 0)) return null
return ( return (
<> <>
@@ -152,6 +159,59 @@ export function FilePreviewList({
</div> </div>
) )
})} })}
{/* URL previews */}
{urlData && urlData.size > 0 && (
<div className="flex flex-wrap gap-2">
{Array.from(urlData.entries()).map(
([url, data], index) => (
<div
key={url + index}
className="relative group"
>
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
<div className="flex flex-col items-center justify-center h-full p-1">
{data.isExtracting ? (
<>
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
<span className="text-[10px] text-muted-foreground">
{dict.file.reading}
</span>
</>
) : (
<>
<Link className="h-6 w-6 text-blue-500 mb-1" />
<span className="text-xs text-center truncate w-full px-1">
{data.title.length > 10
? `${data.title.slice(0, 7)}...`
: data.title}
</span>
{data.charCount && (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
data.charCount,
)}{" "}
{dict.file.chars}
</span>
)}
</>
)}
</div>
</div>
{onRemoveUrl && (
<button
type="button"
onClick={() => onRemoveUrl(url)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={dict.file.removeFile}
>
<X className="h-3 w-3" />
</button>
)}
</div>
),
)}
</div>
)}
</div> </div>
{/* Image Modal/Lightbox */} {/* Image Modal/Lightbox */}
{selectedImage && ( {selectedImage && (

View File

@@ -43,7 +43,7 @@ export function HistoryDialog({
return ( return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}> <Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader> <DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle> <DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -79,6 +79,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud", edgeone: "tencent-cloud",
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope",
} }
// Provider logo component // Provider logo component

View File

@@ -50,6 +50,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud", edgeone: "tencent-cloud",
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope",
} }
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)

View File

@@ -3,6 +3,7 @@
import { Github, Info, Moon, Sun, Tag } from "lucide-react" import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -24,6 +25,7 @@ import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout // Reusable setting item component for consistent layout
function SettingItem({ function SettingItem({
@@ -59,7 +61,6 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
darkMode: boolean darkMode: boolean
@@ -69,7 +70,6 @@ interface SettingsDialogProps {
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required" const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null { function getStoredAccessCodeRequired(): boolean | null {
@@ -82,7 +82,6 @@ function getStoredAccessCodeRequired(): boolean | null {
function SettingsContent({ function SettingsContent({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange,
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
darkMode, darkMode,
@@ -95,13 +94,18 @@ function SettingsContent({
const pathname = usePathname() || "/" const pathname = usePathname() || "/"
const search = useSearchParams() const search = useSearchParams()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false) const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false, () => getStoredAccessCodeRequired() ?? false,
) )
const [currentLang, setCurrentLang] = useState("en") const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => { useEffect(() => {
// Only fetch if not cached in localStorage // Only fetch if not cached in localStorage
@@ -143,13 +147,20 @@ function SettingsContent({
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode) setAccessCode(storedCode)
const storedCloseProtection = localStorage.getItem( const storedSendShortcut = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY, STORAGE_KEYS.sendShortcut,
) )
// Default to true if not set setSendShortcut(storedSendShortcut || "ctrl-enter")
setCloseProtection(storedCloseProtection !== "false")
setError("") setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
} }
}, [open]) }, [open])
@@ -208,6 +219,46 @@ function SettingsContent({
} }
} }
const handleApplyProxy = async () => {
if (!window.electronAPI?.setProxy) return
// Validate proxy URLs (must start with http:// or https://)
const validateProxyUrl = (url: string): boolean => {
if (!url) return true // Empty is OK
return url.startsWith("http://") || url.startsWith("https://")
}
const trimmedHttp = httpProxy.trim()
const trimmedHttps = httpsProxy.trim()
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
toast.error("HTTP Proxy must start with http:// or https://")
return
}
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
toast.error("HTTPS Proxy must start with http:// or https://")
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: trimmedHttp || undefined,
httpsProxy: trimmedHttps || undefined,
})
if (result.success) {
toast.success(dict.settings.proxyApplied)
} else {
toast.error(result.error || "Failed to apply proxy settings")
}
} catch {
toast.error("Failed to apply proxy settings")
} finally {
setIsApplyingProxy(false)
}
}
return ( return (
<DialogContent className="sm:max-w-lg p-0 gap-0"> <DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */} {/* Header */}
@@ -333,25 +384,6 @@ function SettingsContent({
</Button> </Button>
</SettingItem> </SettingItem>
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</SettingItem>
{/* Diagram Style */} {/* Diagram Style */}
<SettingItem <SettingItem
label={dict.settings.diagramStyle} label={dict.settings.diagramStyle}
@@ -370,6 +402,91 @@ function SettingsContent({
</span> </span>
</div> </div>
</SettingItem> </SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
description={dict.settings.sendShortcutDescription}
>
<Select
value={sendShortcut}
onValueChange={(value) => {
setSendShortcut(value)
localStorage.setItem(
STORAGE_KEYS.sendShortcut,
value,
)
window.dispatchEvent(
new CustomEvent("sendShortcutChange", {
detail: value,
}),
)
}}
>
<SelectTrigger
id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{dict.settings.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{dict.settings.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Proxy Settings - Electron only */}
{typeof window !== "undefined" &&
window.electronAPI?.isElectron && (
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{dict.settings.proxy}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.proxyDescription}
</p>
</div>
<div className="space-y-2">
<Input
id="http-proxy"
type="text"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
type="text"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
</div>
<Button
onClick={handleApplyProxy}
disabled={isApplyingProxy}
className="h-9 px-4 rounded-xl w-full"
>
{isApplyingProxy
? "..."
: dict.settings.applyProxy}
</Button>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Link, Loader2 } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useDictionary } from "@/hooks/use-dictionary"
interface UrlInputDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (url: string) => void
isExtracting: boolean
}
export function UrlInputDialog({
open,
onOpenChange,
onSubmit,
isExtracting,
}: UrlInputDialogProps) {
const dict = useDictionary()
const [url, setUrl] = useState("")
const [error, setError] = useState("")
const handleSubmit = () => {
setError("")
if (!url.trim()) {
setError(dict.url.enterUrl)
return
}
try {
new URL(url)
} catch {
setError(dict.url.invalidFormat)
return
}
onSubmit(url.trim())
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isExtracting) {
e.preventDefault()
handleSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.url.title}</DialogTitle>
<DialogDescription>
{dict.url.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value)
setError("")
}}
onKeyDown={handleKeyDown}
placeholder="https://example.com/article"
disabled={isExtracting}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isExtracting}
>
{dict.url.Cancel}
</Button>
<Button
onClick={handleSubmit}
disabled={isExtracting || !url.trim()}
>
{isExtracting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{dict.url.Extracting}
</>
) : (
<>
<Link className="mr-2 h-4 w-4" />
{dict.url.extract}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef, EventAutoSave } from "react-drawio"
import { toast } from "sonner" import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -23,6 +23,7 @@ interface DiagramContextType {
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null> drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleAutoSave: (data: EventAutoSave) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -226,6 +227,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Handle autosave events from draw.io - keeps chartXML in sync with user modifications
const handleAutoSave = (data: EventAutoSave) => {
if (data.xml) {
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)
@@ -350,6 +358,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

78
docs/cn/FAQ.md Normal file
View File

@@ -0,0 +1,78 @@
# 常见问题解答 (FAQ)
---
## 1. 无法导出 PDF
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
**解决方案**: 先导出为图片PNG再打印转成 PDF
**相关 Issue**: #539, #125
---
## 2. 无法访问 embed.diagrams.net离线/内网部署)
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**
**解决方案**: 必须在构建时通过 `args` 传入:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
ports: ["3000:3000"]
env_file: .env
```
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
**相关 Issue**: #295, #317
---
## 3. 自建模型只思考不画图
**问题**: 本地部署的模型(如 Qwen、LiteLLM只输出思考过程不生成图表
**可能原因**:
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
**解决方案**: 开启 tool calling例如 vLLM
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**相关 Issue**: #269, #75
---
## 4. 上传图片后提示"未提供图片"
**问题**: 上传图片后,系统显示"未提供图片"错误
**可能原因**:
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
**解决方案**:
- 使用支持视觉的模型GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- 模型名带 `vision``vl` 的支持图片
- 更新到最新版本v0.4.9+
**相关 Issue**: #324, #421, #469

View File

@@ -37,11 +37,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [安装](#安装) - [安装](#安装)
- [部署](#部署) - [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages) - [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
- [部署到Vercel(推荐)](#部署到vercel推荐) - [部署到Vercel](#部署到vercel)
- [部署到Cloudflare Workers](#部署到cloudflare-workers) - [部署到Cloudflare Workers](#部署到cloudflare-workers)
- [多提供商支持](#多提供商支持) - [多提供商支持](#多提供商支持)
- [工作原理](#工作原理) - [工作原理](#工作原理)
- [支持与联系](#支持与联系) - [支持与联系](#支持与联系)
- [常见问题](#常见问题)
- [Star历史](#star历史) - [Star历史](#star历史)
## 示例 ## 示例
@@ -179,7 +180,7 @@ npm run dev
同时通过腾讯云EdgeOne Pages部署也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。 同时通过腾讯云EdgeOne Pages部署也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
### 部署到Vercel(推荐) ### 部署到Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -204,6 +205,7 @@ npm run dev
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang - SGLang
- Vercel AI Gateway - Vercel AI Gateway
@@ -237,6 +239,10 @@ npm run dev
- 邮箱me[at]jiang.jp - 邮箱me[at]jiang.jp
## 常见问题
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
## Star历史 ## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -152,6 +152,19 @@ AI_PROVIDER=ollama
AI_MODEL=llama3.2 AI_MODEL=llama3.2
``` ```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
可选的自定义端点:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
可选的自定义 URL 可选的自定义 URL
```bash ```bash

78
docs/en/FAQ.md Normal file
View File

@@ -0,0 +1,78 @@
# Frequently Asked Questions (FAQ)
---
## 1. Cannot Export PDF
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
**Solution**: Export as image (PNG) first, then print to PDF
**Related Issues**: #539, #125
---
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
**Solution**: Must pass via `args` at build time:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
ports: ["3000:3000"]
env_file: .env
```
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
**Related Issues**: #295, #317
---
## 3. Self-hosted Model Only Thinks But Doesn't Draw
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
**Possible Causes**:
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
2. **Tool calling not enabled** - Model service needs tool use configuration
**Solution**: Enable tool calling, e.g., vLLM:
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**Related Issues**: #269, #75
---
## 4. "No Image Provided" After Uploading Image
**Problem**: After uploading an image, the system shows "No image provided" error
**Possible Causes**:
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
**Solution**:
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
- Models with `vision` or `vl` in name support images
- Update to latest version (v0.4.9+)
**Related Issues**: #324, #421, #469

View File

@@ -158,6 +158,19 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
``` ```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
Optional custom endpoint:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
### Vercel AI Gateway ### Vercel AI Gateway
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys. Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
@@ -201,7 +214,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 AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
``` ```
## Model Capability Requirements ## Model Capability Requirements

78
docs/ja/FAQ.md Normal file
View File

@@ -0,0 +1,78 @@
# よくある質問 (FAQ)
---
## 1. PDFをエクスポートできない
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
**解決策**: まず画像PNGとしてエクスポートし、その後PDFに印刷する
**関連Issue**: #539, #125
---
## 2. embed.diagrams.netにアクセスできないオフライン/イントラネットデプロイ)
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
**解決策**: ビルド時に `args` で渡す必要があります:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
ports: ["3000:3000"]
env_file: .env
```
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
**関連Issue**: #295, #317
---
## 3. 自前モデルが思考するだけで描画しない
**問題**: ローカルデプロイのモデルQwen、LiteLLMなどが思考過程のみを出力し、図表を生成しない
**考えられる原因**:
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
**解決策**: tool callingを有効にする、例えばvLLM
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**関連Issue**: #269, #75
---
## 4. 画像アップロード後「画像が提供されていません」と表示される
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
**考えられる原因**:
1. モデルがビジョン機能をサポートしていないKimi K2、DeepSeek、Qwenテキストモデルなど
**解決策**:
- ビジョン対応モデルを使用GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
- 最新バージョンv0.4.9+)にアップデート
**関連Issue**: #324, #421, #469

View File

@@ -37,11 +37,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [インストール](#インストール) - [インストール](#インストール)
- [デプロイ](#デプロイ) - [デプロイ](#デプロイ)
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ) - [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨) - [Vercelへのデプロイ](#vercelへのデプロイ)
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ) - [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
- [マルチプロバイダーサポート](#マルチプロバイダーサポート) - [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み) - [仕組み](#仕組み)
- [サポート&お問い合わせ](#サポートお問い合わせ) - [サポート&お問い合わせ](#サポートお問い合わせ)
- [よくある質問](#よくある質問)
- [スター履歴](#スター履歴) - [スター履歴](#スター履歴)
## 例 ## 例
@@ -180,7 +181,7 @@ npm run dev
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。 また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
### Vercelへのデプロイ(推奨) ### Vercelへのデプロイ
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -205,6 +206,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang - SGLang
- Vercel AI Gateway - Vercel AI Gateway
@@ -238,6 +240,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
- メールme[at]jiang.jp - メールme[at]jiang.jp
## よくある質問
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
## スター履歴 ## スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -158,6 +158,19 @@ AI_MODEL=llama3.2
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
``` ```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
任意のカスタムエンドポイント:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
### Vercel AI Gateway ### Vercel AI Gateway
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。 Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。

View File

@@ -25,6 +25,19 @@ interface ApplyPresetResult {
env?: Record<string, string> env?: Record<string, string>
} }
/** Proxy configuration interface */
interface ProxyConfig {
httpProxy?: string
httpsProxy?: string
}
/** Result of setting proxy */
interface SetProxyResult {
success: boolean
error?: string
devMode?: boolean
}
declare global { declare global {
interface Window { interface Window {
/** Main window Electron API */ /** Main window Electron API */
@@ -45,6 +58,10 @@ declare global {
openFile: () => Promise<string | null> openFile: () => Promise<string | null>
/** Save data to file via save dialog */ /** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean> saveFile: (data: string) => Promise<boolean>
/** Get proxy configuration */
getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
} }
/** Settings window Electron API */ /** Settings window Electron API */
@@ -71,4 +88,4 @@ declare global {
} }
} }
export { ConfigPreset, ApplyPresetResult } export { ConfigPreset, ApplyPresetResult, ProxyConfig, SetProxyResult }

View File

@@ -351,6 +351,10 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
apiKey: "SILICONFLOW_API_KEY", apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL", baseUrl: "SILICONFLOW_BASE_URL",
}, },
modelscope: {
apiKey: "MODELSCOPE_API_KEY",
baseUrl: "MODELSCOPE_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" }, gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock and ollama don't use API keys in the same way // bedrock and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" }, bedrock: { apiKey: "", baseUrl: "" },

View File

@@ -4,6 +4,7 @@ import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader" import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers" import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server" import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window" import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager" import { createWindow, getMainWindow } from "./window-manager"
@@ -24,6 +25,9 @@ if (!gotTheLock) {
// Load environment variables from .env files // Load environment variables from .env files
loadEnvFile() loadEnvFile()
// Apply proxy settings from saved config
applyProxyToEnv()
// Apply saved preset environment variables (overrides .env) // Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv() const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) { for (const [key, value] of Object.entries(presetEnv)) {

View File

@@ -11,6 +11,12 @@ import {
updatePreset, updatePreset,
} from "./config-manager" } from "./config-manager"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import {
applyProxyToEnv,
getProxyConfig,
type ProxyConfig,
saveProxyConfig,
} from "./proxy-manager"
/** /**
* Allowed configuration keys for presets * Allowed configuration keys for presets
@@ -209,4 +215,40 @@ export function registerIpcHandlers(): void {
return setCurrentPreset(id) return setCurrentPreset(id)
}, },
) )
// ==================== Proxy Settings ====================
ipcMain.handle("get-proxy", () => {
return getProxyConfig()
})
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
try {
// Save config to file
saveProxyConfig(config)
// Apply to current process environment
applyProxyToEnv()
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development, env vars are already applied
// Next.js dev server may need manual restart
return { success: true, devMode: true }
}
// Production: restart Next.js server to pick up new env vars
await restartNextServer()
return { success: true }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to apply proxy settings",
}
}
})
} }

View File

@@ -69,6 +69,8 @@ export async function startNextServer(): Promise<string> {
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "localhost", HOSTNAME: "localhost",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
} }
// Set cache directory to a writable location (user's app data folder) // Set cache directory to a writable location (user's app data folder)
@@ -85,6 +87,13 @@ export async function startNextServer(): Promise<string> {
} }
} }
// Debug: log proxy-related env vars
console.log("Proxy env vars being passed to server:", {
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
})
// Use Electron's utilityProcess API for running Node.js in background // Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron // This is the recommended way to run Node.js code in Electron
serverProcess = utilityProcess.fork(serverPath, [], { serverProcess = utilityProcess.fork(serverPath, [], {
@@ -114,13 +123,41 @@ export async function startNextServer(): Promise<string> {
} }
/** /**
* Stop the Next.js server process * Stop the Next.js server process and wait for it to exit
*/ */
export function stopNextServer(): void { export async function stopNextServer(): Promise<void> {
if (serverProcess) { if (serverProcess) {
console.log("Stopping Next.js server...") console.log("Stopping Next.js server...")
// Create a promise that resolves when the process exits
const exitPromise = new Promise<void>((resolve) => {
const proc = serverProcess
if (!proc) {
resolve()
return
}
const onExit = () => {
resolve()
}
proc.once("exit", onExit)
// Timeout after 5 seconds
setTimeout(() => {
proc.removeListener("exit", onExit)
resolve()
}, 5000)
})
serverProcess.kill() serverProcess.kill()
serverProcess = null serverProcess = null
// Wait for process to exit
await exitPromise
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
} }
} }
@@ -150,8 +187,8 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
export async function restartNextServer(): Promise<string> { export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...") console.log("Restarting Next.js server...")
// Stop the current server // Stop the current server and wait for it to exit
stopNextServer() await stopNextServer()
// Wait for the port to be released // Wait for the port to be released
await waitForServerStop() await waitForServerStop()

View File

@@ -0,0 +1,75 @@
import { app } from "electron"
import * as fs from "fs"
import * as path from "path"
import type { ProxyConfig } from "../electron.d"
export type { ProxyConfig }
const CONFIG_FILE = "proxy-config.json"
function getConfigPath(): string {
return path.join(app.getPath("userData"), CONFIG_FILE)
}
/**
* Load proxy configuration from JSON file
*/
export function loadProxyConfig(): ProxyConfig {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, "utf-8")
return JSON.parse(data) as ProxyConfig
}
} catch (error) {
console.error("Failed to load proxy config:", error)
}
return {}
}
/**
* Save proxy configuration to JSON file
*/
export function saveProxyConfig(config: ProxyConfig): void {
try {
const configPath = getConfigPath()
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save proxy config:", error)
throw error
}
}
/**
* Apply proxy configuration to process.env
* Must be called BEFORE starting the Next.js server
*/
export function applyProxyToEnv(): void {
const config = loadProxyConfig()
if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy
} else {
delete process.env.HTTP_PROXY
delete process.env.http_proxy
}
if (config.httpsProxy) {
process.env.HTTPS_PROXY = config.httpsProxy
process.env.https_proxy = config.httpsProxy
} else {
delete process.env.HTTPS_PROXY
delete process.env.https_proxy
}
}
/**
* Get current proxy configuration (from process.env)
*/
export function getProxyConfig(): ProxyConfig {
return {
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
}
}

View File

@@ -21,4 +21,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
// File operations // File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"), openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data), saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
// Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
ipcRenderer.invoke("set-proxy", config),
}) })

View File

@@ -55,6 +55,7 @@
<option value="openrouter">OpenRouter</option> <option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option> <option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option> <option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option> <option value="ollama">Ollama (Local)</option>
</select> </select>
</div> </div>

View File

@@ -288,6 +288,7 @@ function getProviderLabel(provider) {
openrouter: "OpenRouter", openrouter: "OpenRouter",
deepseek: "DeepSeek", deepseek: "DeepSeek",
siliconflow: "SiliconFlow", siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama", ollama: "Ollama",
} }
return labels[provider] || provider return labels[provider] || provider

View File

@@ -72,6 +72,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SGLANG_API_KEY=your-sglang-api-key # SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint # SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# ModelScope Configuration
# MODELSCOPE_API_KEY=ms-...
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
# ByteDance Doubao Configuration (via Volcengine) # ByteDance Doubao Configuration (via Volcengine)
# DOUBAO_API_KEY=your-doubao-api-key # DOUBAO_API_KEY=your-doubao-api-key
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint # DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint

View File

@@ -1,4 +1,5 @@
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
@@ -29,12 +30,6 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void type AddToolOutputFn = (params: AddToolOutputParams) => void
interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
interface UseDiagramToolHandlersParams { interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string> partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>> editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>

View File

@@ -1,26 +0,0 @@
import { STORAGE_KEYS } from "./storage"
/**
* Get AI configuration from localStorage.
* Returns API keys and settings for custom AI providers.
* Used to override server defaults when user provides their own API key.
*/
export function getAIConfig() {
if (typeof window === "undefined") {
return {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
}
}
return {
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
}
}

View File

@@ -8,21 +8,9 @@ import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2" import { createOllama, ollama } from "ollama-ai-provider-v2"
import type { ProviderName } from "@/lib/types/model-config"
export type ProviderName = export type { ProviderName }
| "bedrock"
| "openai"
| "anthropic"
| "google"
| "azure"
| "ollama"
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
| "edgeone"
| "doubao"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -59,6 +47,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"gateway", "gateway",
"edgeone", "edgeone",
"doubao", "doubao",
"modelscope",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -73,6 +62,32 @@ const ANTHROPIC_BETA_HEADERS = {
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14", "anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
} }
/**
* Resolve baseURL based on whether user is providing their own API key.
* When user provides their own API key, we should NOT fall back to server's
* baseURL environment variable - user credentials should only be sent to
* user-specified endpoints or official provider endpoints.
*
* @param userApiKey - User-provided API key (if any)
* @param userBaseUrl - User-provided base URL (if any)
* @param serverBaseUrl - Server's base URL from environment variable
* @param defaultBaseUrl - Provider's official/default base URL (optional)
* @returns The resolved base URL to use
*/
export function resolveBaseURL(
userApiKey: string | null | undefined,
userBaseUrl: string | null | undefined,
serverBaseUrl: string | undefined,
defaultBaseUrl?: string,
): string | undefined {
if (userApiKey) {
// User provides their own API key - only use user's baseUrl or default
return userBaseUrl || defaultBaseUrl || undefined
}
// No user API key - fall back to server config
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
}
/** /**
* Safely parse integer from environment variable with validation * Safely parse integer from environment variable with validation
*/ */
@@ -353,6 +368,7 @@ function buildProviderOptions(
case "siliconflow": case "siliconflow":
case "sglang": case "sglang":
case "gateway": case "gateway":
case "modelscope":
case "doubao": { case "doubao": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
@@ -381,6 +397,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
gateway: "AI_GATEWAY_API_KEY", gateway: "AI_GATEWAY_API_KEY",
edgeone: null, // No credentials needed - uses EdgeOne Edge AI edgeone: null, // No credentials needed - uses EdgeOne Edge AI
doubao: "DOUBAO_API_KEY", doubao: "DOUBAO_API_KEY",
modelscope: "MODELSCOPE_API_KEY",
} }
/** /**
@@ -445,7 +462,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables * Get the AI model based on environment variables
* *
* Environment variables: * Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway) * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_MODEL: The model ID/name for the selected provider * - AI_MODEL: The model ID/name for the selected provider
* *
* Provider-specific env vars: * Provider-specific env vars:
@@ -460,9 +477,11 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1) * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1)
* - SGLANG_API_KEY: SGLang API key * - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional) * - SGLANG_BASE_URL: SGLang endpoint (optional)
* - MODELSCOPE_API_KEY: ModelScope API key
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
*/ */
export function getAIModel(overrides?: ClientOverrides): ModelConfig { export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -537,6 +556,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` + `- SGLANG_API_KEY for SGLang\n` +
`- MODELSCOPE_API_KEY for ModelScope\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`, `Or set AI_PROVIDER=ollama for local Ollama.`,
) )
} else { } else {
@@ -601,7 +621,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openai": { case "openai": {
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENAI_BASE_URL,
)
if (baseURL) { if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API // Custom base URL = third-party proxy, use Chat Completions API
// for compatibility (most proxies don't support /responses endpoint) // for compatibility (most proxies don't support /responses endpoint)
@@ -620,10 +644,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "anthropic": { case "anthropic": {
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.ANTHROPIC_BASE_URL || overrides?.baseUrl,
"https://api.anthropic.com/v1" process.env.ANTHROPIC_BASE_URL,
"https://api.anthropic.com/v1",
)
const customProvider = createAnthropic({ const customProvider = createAnthropic({
apiKey, apiKey,
baseURL, baseURL,
@@ -638,7 +664,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "google": { case "google": {
const apiKey = const apiKey =
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.GOOGLE_BASE_URL,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({ const customGoogle = createGoogleGenerativeAI({
apiKey, apiKey,
@@ -653,8 +683,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "azure": { case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL const baseURL = resolveBaseURL(
const resourceName = process.env.AZURE_RESOURCE_NAME overrides?.apiKey,
overrides?.baseUrl,
process.env.AZURE_BASE_URL,
)
// Only use server's resourceName if user is NOT providing their own API key
const resourceName = overrides?.apiKey
? undefined
: process.env.AZURE_RESOURCE_NAME
// Azure requires either baseURL or resourceName to construct the endpoint // Azure requires either baseURL or resourceName to construct the endpoint
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
if (baseURL || resourceName || overrides?.apiKey) { if (baseURL || resourceName || overrides?.apiKey) {
@@ -684,8 +721,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "openrouter": { case "openrouter": {
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL overrides?.apiKey,
overrides?.baseUrl,
process.env.OPENROUTER_BASE_URL,
)
const openrouter = createOpenRouter({ const openrouter = createOpenRouter({
apiKey, apiKey,
...(baseURL && { baseURL }), ...(baseURL && { baseURL }),
@@ -696,7 +736,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "deepseek": { case "deepseek": {
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.DEEPSEEK_BASE_URL,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({ const customDeepSeek = createDeepSeek({
apiKey, apiKey,
@@ -711,10 +755,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "siliconflow": { case "siliconflow": {
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.SILICONFLOW_BASE_URL || overrides?.baseUrl,
"https://api.siliconflow.com/v1" process.env.SILICONFLOW_BASE_URL,
"https://api.siliconflow.cn/v1",
)
const siliconflowProvider = createOpenAI({ const siliconflowProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,
@@ -725,11 +771,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "sglang": { case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.SGLANG_BASE_URL,
)
const sglangProvider = createOpenAI({ const sglangProvider = createOpenAI({
apiKey, apiKey,
baseURL, ...(baseURL && { baseURL }),
// Add a custom fetch wrapper to intercept and fix the stream from sglang // Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => { fetch: async (url, options) => {
const response = await fetch(url, options) const response = await fetch(url, options)
@@ -834,8 +884,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway // See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL overrides?.apiKey,
overrides?.baseUrl,
process.env.AI_GATEWAY_BASE_URL,
)
// Only use custom configuration if explicitly set (local dev or custom Gateway) // Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
@@ -867,21 +920,52 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "doubao": { case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const baseURL = const baseURL = resolveBaseURL(
overrides?.baseUrl || overrides?.apiKey,
process.env.DOUBAO_BASE_URL || overrides?.baseUrl,
"https://ark.cn-beijing.volces.com/api/v3" process.env.DOUBAO_BASE_URL,
const doubaoProvider = createDeepSeek({ "https://ark.cn-beijing.volces.com/api/v3",
)
const lowerModelId = modelId.toLowerCase()
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
} else {
const doubaoProvider = createOpenAI({
apiKey,
baseURL,
})
model = doubaoProvider.chat(modelId)
}
break
}
case "modelscope": {
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
process.env.MODELSCOPE_BASE_URL,
"https://api-inference.modelscope.cn/v1",
)
const modelscopeProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,
}) })
model = doubaoProvider(modelId) model = modelscopeProvider.chat(modelId)
break 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`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
) )
} }

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow" "siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
@@ -51,7 +52,8 @@
"badResponse": "Bad response", "badResponse": "Bad response",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
"editMessage": "Edit message", "editMessage": "Edit message",
"saveAndSubmit": "Save & Submit" "saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
}, },
"examples": { "examples": {
"title": "Create diagrams with AI", "title": "Create diagrams with AI",
@@ -98,14 +100,22 @@
"switchTo": "Switch to", "switchTo": "Switch to",
"minimal": "Minimal", "minimal": "Minimal",
"sketch": "Sketch", "sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style", "diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.", "diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions", "diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports", "diagramActionsDescription": "Manage diagram history and exports",
"history": "History", "history": "History",
"download": "Download" "download": "Download",
"proxy": "Proxy Settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy",
"applyProxy": "Apply",
"proxyApplied": "Proxy settings applied"
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -186,6 +196,15 @@
"chars": "chars", "chars": "chars",
"removeFile": "Remove file" "removeFile": "Remove file"
}, },
"url": {
"title": "Extract Content from URL",
"description": "Paste a URL to extract and analyze its content",
"Extracting": "Extracting...",
"extract": "Extract",
"Cancel": "Cancel",
"enterUrl": "Please enter a URL",
"invalidFormat": "Invalid URL format"
},
"reasoning": { "reasoning": {
"thinking": "Thinking...", "thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds", "thoughtFor": "Thought for {duration} seconds",

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow" "siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -51,7 +52,8 @@
"badResponse": "悪い応答", "badResponse": "悪い応答",
"clickToEdit": "クリックして編集", "clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集", "editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信" "saveAndSubmit": "保存して送信",
"ExtractURL": "URLから抽出"
}, },
"examples": { "examples": {
"title": "AI でダイアグラムを作成", "title": "AI でダイアグラムを作成",
@@ -98,14 +100,22 @@
"switchTo": "切り替え", "switchTo": "切り替え",
"minimal": "ミニマル", "minimal": "ミニマル",
"sketch": "スケッチ", "sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル", "diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。", "diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作", "diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理", "diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴", "history": "履歴",
"download": "ダウンロード" "download": "ダウンロード",
"proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ",
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました"
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -186,6 +196,15 @@
"chars": "文字", "chars": "文字",
"removeFile": "ファイルを削除" "removeFile": "ファイルを削除"
}, },
"url": {
"title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
},
"reasoning": { "reasoning": {
"thinking": "考え中...", "thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました", "thoughtFor": "{duration} 秒考えました",

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow" "siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "描述您的图表或上传文件...", "placeholder": "描述您的图表或上传文件...",
@@ -51,7 +52,8 @@
"badResponse": "无帮助", "badResponse": "无帮助",
"clickToEdit": "点击编辑", "clickToEdit": "点击编辑",
"editMessage": "编辑消息", "editMessage": "编辑消息",
"saveAndSubmit": "保存并提交" "saveAndSubmit": "保存并提交",
"ExtractURL": "从 URL 提取"
}, },
"examples": { "examples": {
"title": "用 AI 创建图表", "title": "用 AI 创建图表",
@@ -98,14 +100,22 @@
"switchTo": "切换到", "switchTo": "切换到",
"minimal": "简约", "minimal": "简约",
"sketch": "草图", "sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式", "diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。", "diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
"diagramActions": "图表操作", "diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出", "diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录", "history": "历史记录",
"download": "下载" "download": "下载",
"proxy": "代理设置",
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用"
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -186,6 +196,15 @@
"chars": "字符", "chars": "字符",
"removeFile": "移除文件" "removeFile": "移除文件"
}, },
"url": {
"title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
},
"reasoning": { "reasoning": {
"thinking": "思考中...", "thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒", "thoughtFor": "思考了 {duration} 秒",

View File

@@ -12,7 +12,6 @@ export const STORAGE_KEYS = {
// Settings // Settings
accessCode: "next-ai-draw-io-access-code", accessCode: "next-ai-draw-io-access-code",
closeProtection: "next-ai-draw-io-close-protection",
accessCodeRequired: "next-ai-draw-io-access-code-required", accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider", aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url", aiBaseUrl: "next-ai-draw-io-ai-base-url",
@@ -22,4 +21,7 @@ export const STORAGE_KEYS = {
// Multi-model configuration // Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs", modelConfigs: "next-ai-draw-io-model-configs",
selectedModelId: "next-ai-draw-io-selected-model-id", selectedModelId: "next-ai-draw-io-selected-model-id",
// Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut",
} as const } as const

View File

@@ -1,39 +0,0 @@
/**
* Token counting utilities using js-tiktoken
*
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
* This is a pure JavaScript implementation, no WASM required.
*/
import { encodingForModel } from "js-tiktoken"
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
const encoder = encodingForModel("gpt-4o")
/**
* Count the number of tokens in a text string
* @param text - The text to count tokens for
* @returns The number of tokens
*/
export function countTextTokens(text: string): number {
return encoder.encode(text).length
}
/**
* Get token counts for the system prompts
* Useful for debugging and optimizing prompt sizes
* @returns Object with token counts for default and extended prompts
*/
export function getSystemPromptTokenCounts(): {
default: number
extended: number
additions: number
} {
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
return {
default: defaultTokens,
extended: extendedTokens,
additions: extendedTokens - defaultTokens,
}
}

View File

@@ -6,6 +6,7 @@ export type ProviderName =
| "google" | "google"
| "azure" | "azure"
| "bedrock" | "bedrock"
| "ollama"
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
@@ -13,6 +14,7 @@ export type ProviderName =
| "gateway" | "gateway"
| "edgeone" | "edgeone"
| "doubao" | "doubao"
| "modelscope"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -75,11 +77,15 @@ export const PROVIDER_INFO: Record<
google: { label: "Google" }, google: { label: "Google" },
azure: { label: "Azure OpenAI" }, azure: { label: "Azure OpenAI" },
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: {
label: "Ollama",
defaultBaseUrl: "http://localhost:11434",
},
openrouter: { label: "OpenRouter" }, openrouter: { label: "OpenRouter" },
deepseek: { label: "DeepSeek" }, deepseek: { label: "DeepSeek" },
siliconflow: { siliconflow: {
label: "SiliconFlow", label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.com/v1", defaultBaseUrl: "https://api.siliconflow.cn/v1",
}, },
sglang: { sglang: {
label: "SGLang", label: "SGLang",
@@ -91,10 +97,14 @@ export const PROVIDER_INFO: Record<
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
}, },
modelscope: {
label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
},
} }
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = { export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [ openai: [
"gpt-5.2-pro", "gpt-5.2-pro",
"gpt-5.2-chat-latest", "gpt-5.2-chat-latest",
@@ -231,6 +241,17 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
"doubao-pro-32k-241215", "doubao-pro-32k-241215",
"doubao-pro-256k-241215", "doubao-pro-256k-241215",
], ],
modelscope: [
// Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-32B",
// DeepSeek
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2",
],
} }
// Helper to generate UUID // Helper to generate UUID

49
lib/url-utils.ts Normal file
View File

@@ -0,0 +1,49 @@
import { z } from "zod"
export interface UrlData {
url: string
title: string
content: string
charCount: number
isExtracting: boolean
}
const UrlResponseSchema = z.object({
title: z.string().default("Untitled"),
content: z.string(),
charCount: z.number().int().nonnegative(),
})
export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch("/api/parse-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
// Try to parse JSON once
const raw = await response
.json()
.catch(() => ({ error: "Unexpected non-JSON response" }))
if (!response.ok) {
const message =
typeof raw === "object" && raw && "error" in raw
? String((raw as any).error)
: "Failed to extract URL content"
throw new Error(message)
}
const parsed = UrlResponseSchema.safeParse(raw)
if (!parsed.success) {
throw new Error("Malformed response from URL extraction API")
}
return {
url,
title: parsed.data.title,
content: parsed.data.content,
charCount: parsed.data.charCount,
isExtracting: false,
}
}

View File

@@ -1,6 +1,9 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import * as pako from "pako" import * as pako from "pako"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import type { DiagramOperation } from "@/components/chat/types"
export type { DiagramOperation }
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -473,12 +476,6 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// ID-based Diagram Operations // ID-based Diagram Operations
// ============================================================================ // ============================================================================
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface OperationError { export interface OperationError {
type: "update" | "add" | "delete" type: "update" | "add" | "delete"
cellId: string cellId: string

3844
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.9", "version": "0.4.10",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -24,6 +24,7 @@
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml", "dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac", "dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win", "dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:win:build": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux", "dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux", "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
"test": "vitest", "test": "vitest",
@@ -40,14 +41,15 @@
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.7.2", "@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.14.7", "@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -67,10 +69,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"js-tiktoken": "^1.0.21",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^2.0.0",
@@ -87,6 +89,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"unpdf": "^1.4.0", "unpdf": "^1.4.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@@ -115,6 +118,7 @@
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
@@ -133,7 +137,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.54.0" "wrangler": "4.58.0"
}, },
"overrides": { "overrides": {
"@openrouter/ai-sdk-provider": { "@openrouter/ai-sdk-provider": {

View File

@@ -0,0 +1,11 @@
{
"name": "next-ai-drawio",
"version": "1.0.0",
"description": "AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.",
"author": {
"name": "DayuanJiang"
},
"repository": "https://github.com/DayuanJiang/next-ai-draw-io",
"homepage": "https://next-ai-drawio.jiang.jp",
"license": "Apache-2.0"
}

View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}

View File

@@ -0,0 +1,107 @@
# Next AI Draw.io - Claude Code Plugin
AI-powered Draw.io diagram generation with real-time browser preview for Claude Code.
## Installation
### From Plugin Directory (Coming Soon)
Once approved, install via:
```
/plugin install next-ai-drawio
```
### Manual Installation
```bash
claude --plugin-dir /path/to/packages/claude-plugin
```
Or add the MCP server directly:
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
- **Version History**: Restore previous diagram versions with visual thumbnails
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, no external dependencies required
## Use Case Examples
### 1. Create Architecture Diagrams
```
Generate an AWS architecture diagram with Lambda, API Gateway, DynamoDB,
and S3 for a serverless REST API
```
### 2. Flowchart Generation
```
Create a flowchart showing the CI/CD pipeline: code commit -> build ->
test -> staging deploy -> production deploy with approval gates
```
### 3. System Design Documentation
```
Design a microservices e-commerce system with user service, product catalog,
shopping cart, order processing, and payment gateway
```
### 4. Cloud Architecture (AWS/GCP/Azure)
```
Generate a GCP architecture diagram with Cloud Run, Cloud SQL, and
Cloud Storage for a web application
```
### 5. Sequence Diagrams
```
Create a sequence diagram showing OAuth 2.0 authorization code flow
between user, client app, auth server, and resource server
```
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
## How It Works
```
Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
```
1. Ask Claude to create a diagram
2. Claude calls `start_session` to open a browser window
3. Claude generates diagram XML and sends it to the browser
4. You see the diagram update in real-time!
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
## Links
- [Homepage](https://next-ai-drawio.jiang.jp)
- [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io)
- [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server)
## License
Apache-2.0

View File

@@ -1,18 +1,18 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.6", "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.6", "version": "0.1.12",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^4.0.0"
}, },
"bin": { "bin": {
"next-ai-drawio-mcp": "dist/index.js" "next-ai-drawio-mcp": "dist/index.js"
@@ -481,9 +481,9 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.1", "version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
@@ -520,9 +520,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.4", "version": "24.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2051,9 +2051,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.11", "version": "0.1.12",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -21,16 +21,16 @@
"claude", "claude",
"model-context-protocol" "model-context-protocol"
], ],
"author": "Biki-dev", "author": "DayuanJiang",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Biki-dev/next-ai-draw-io", "url": "https://github.com/DayuanJiang/next-ai-draw-io",
"directory": "packages/mcp-server" "directory": "packages/mcp-server"
}, },
"homepage": "https://next-ai-drawio.jiang.jp", "homepage": "https://next-ai-drawio.jiang.jp",
"bugs": { "bugs": {
"url": "https://github.com/Biki-dev/next-ai-draw-io/issues" "url": "https://github.com/DayuanJiang/next-ai-draw-io/issues"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -38,4 +38,12 @@ const targetStaticDir = join(targetDir, ".next", "static")
mkdirSync(targetStaticDir, { recursive: true }) mkdirSync(targetStaticDir, { recursive: true })
cpSync(staticDir, targetStaticDir, { recursive: true }) cpSync(staticDir, targetStaticDir, { recursive: true })
// Copy public folder (required for favicon-white.svg and other assets)
console.log("Copying public folder...")
const publicDir = join(rootDir, "public")
const targetPublicDir = join(targetDir, "public")
if (existsSync(publicDir)) {
cpSync(publicDir, targetPublicDir, { recursive: true })
}
console.log("Done! Files prepared in electron-standalone/") console.log("Done! Files prepared in electron-standalone/")

View File

@@ -1,5 +1,141 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers" import {
resolveBaseURL,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
describe("resolveBaseURL", () => {
const SERVER_BASE_URL = "https://server-proxy.example.com"
const USER_BASE_URL = "https://user-proxy.example.com"
const DEFAULT_BASE_URL = "https://api.provider.com/v1"
const USER_API_KEY = "user-api-key-123"
describe("when user provides their own API key", () => {
it("uses user's baseUrl when provided", () => {
const result = resolveBaseURL(
USER_API_KEY,
USER_BASE_URL,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(USER_BASE_URL)
})
it("uses default baseUrl when user provides no baseUrl", () => {
const result = resolveBaseURL(
USER_API_KEY,
null,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(DEFAULT_BASE_URL)
})
it("returns undefined when user provides no baseUrl and no default exists", () => {
const result = resolveBaseURL(
USER_API_KEY,
null,
SERVER_BASE_URL,
undefined,
)
expect(result).toBeUndefined()
})
it("does NOT use server's baseUrl even when available", () => {
const result = resolveBaseURL(
USER_API_KEY,
undefined,
SERVER_BASE_URL,
undefined,
)
// Should NOT return SERVER_BASE_URL
expect(result).not.toBe(SERVER_BASE_URL)
expect(result).toBeUndefined()
})
it("prefers user's baseUrl over default", () => {
const result = resolveBaseURL(
USER_API_KEY,
USER_BASE_URL,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(USER_BASE_URL)
})
})
describe("when using server credentials (no user API key)", () => {
it("uses user's baseUrl when provided (overrides server)", () => {
const result = resolveBaseURL(
null,
USER_BASE_URL,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(USER_BASE_URL)
})
it("falls back to server's baseUrl when no user baseUrl", () => {
const result = resolveBaseURL(
null,
null,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(SERVER_BASE_URL)
})
it("falls back to default when no user or server baseUrl", () => {
const result = resolveBaseURL(
null,
null,
undefined,
DEFAULT_BASE_URL,
)
expect(result).toBe(DEFAULT_BASE_URL)
})
it("returns undefined when no baseUrl available anywhere", () => {
const result = resolveBaseURL(null, null, undefined, undefined)
expect(result).toBeUndefined()
})
it("handles undefined apiKey same as null", () => {
const result = resolveBaseURL(
undefined,
null,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
expect(result).toBe(SERVER_BASE_URL)
})
})
describe("edge cases", () => {
it("handles empty string apiKey as falsy (uses server config)", () => {
const result = resolveBaseURL(
"",
null,
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
// Empty string is falsy, so should use server config
expect(result).toBe(SERVER_BASE_URL)
})
it("handles empty string baseUrl as falsy", () => {
const result = resolveBaseURL(
USER_API_KEY,
"",
SERVER_BASE_URL,
DEFAULT_BASE_URL,
)
// Empty string baseUrl is falsy, should fall back to default
expect(result).toBe(DEFAULT_BASE_URL)
})
})
})
describe("supportsPromptCaching", () => { describe("supportsPromptCaching", () => {
it("returns true for Claude models", () => { it("returns true for Claude models", () => {