Compare commits

..

35 Commits

Author SHA1 Message Date
dayuan.jiang
5a9fed2d31 fix: add continuation retry limit for truncated diagrams
Previously, continuation mode (for truncated XML) had unlimited client-side
retries, relying only on server stepCountIs(5) limit. This could cause
excessive API calls (495 observed) when XML truncation kept occurring.

Added MAX_CONTINUATION_RETRY_COUNT=2 to limit continuation attempts:
- After 2 failed continuation attempts, shows error toast and stops
- Resets on successful completion or user-initiated message
- Also resets when quota limits are hit
2025-12-23 14:10:51 +09:00
Dayuan Jiang
a0fbc0ad33 fix: use last user message for Langfuse trace input (#371)
In multi-step tool flows, messages array contains assistant messages
from previous steps. Using messages[messages.length - 1] would record
the assistant's response as trace input instead of the user's question.
2025-12-23 13:43:28 +09:00
Dayuan Jiang
0385c45a10 fix: OpenAI reasoning/thinking blocks not showing (#370)
- Use Responses API instead of Chat Completions API for OpenAI
  (.chat() -> default call) to support reasoning events
- Add o4 to reasoning model detection
- Change default reasoningSummary from 'detailed' to 'auto'
  (not all models support 'detailed')
- Update types to match AI SDK: 'auto' | 'detailed'
2025-12-23 13:38:50 +09:00
Dayuan Jiang
5262b7bfb2 chore: upgrade AI SDK to v6.0.1 (#369)
- Upgrade ai package from ^5.0.89 to ^6.0.1
- Upgrade @ai-sdk/* provider packages to latest v3/v4
- Update convertToModelMessages call to async (new API)
- Fix usage.cachedInputTokens to usage.inputTokenDetails?.cacheReadTokens
2025-12-23 13:31:42 +09:00
Dayuan Jiang
8cb7494d16 feat(i18n): add translations for model configuration UI (#368)
- Add ~40 new translation keys for model-config-dialog and model-selector
- Support English, Chinese, and Japanese translations
- Replace all hardcoded strings with dictionary lookups
2025-12-23 11:42:27 +09:00
Dayuan Jiang
98625dd72a docs: update about page model info to Haiku 4.5 (#367) 2025-12-23 10:22:31 +09:00
Dayuan Jiang
b5734aa5e1 chore: hide notice icon from header (#366) 2025-12-23 10:08:14 +09:00
Dayuan Jiang
87cdc53665 fix: improve Langfuse span filter to exclude all Next.js infrastructure traces (#365)
* debug: add log to verify instrumentation initialization

* fix: improve Langfuse span filter to exclude all Next.js infrastructure traces
2025-12-23 09:47:23 +09:00
Dayuan Jiang
b4fc259de8 chore: bump version to 0.4.6 (#364)
* chore: bump version to 0.4.6

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-23 09:09:39 +09:00
Dayuan Jiang
28f9a81e7b chore: add build-time arg for showing About and Notice (#360) 2025-12-23 01:06:42 +09:00
Dayuan Jiang
0f67884ead fix: include instrumentation.ts in standalone build for Langfuse (#359)
Add outputFileTracingIncludes to next.config.ts to ensure instrumentation.ts
is included in standalone builds (required for App Runner deployment)
2025-12-23 01:03:11 +09:00
Dayuan Jiang
3521495ead chore: conditionally show about and notice based on env var (#358) 2025-12-23 00:32:22 +09:00
Dayuan Jiang
6446454cd7 fix: add SSRF protection to validate-model endpoint (#357)
Block private IPs, localhost, cloud metadata endpoints (169.254.169.254),
and internal hostnames in custom baseUrl parameter to prevent server-side
request forgery attacks.
2025-12-23 00:26:01 +09:00
Biki Kalita
84959637db Support subdirectory deployment and fix API path handling (#311)
* feat: support subdirectory deployment (NEXT_PUBLIC_BASE_PATH)

* removed unwanted check and fix favicon issue

* Use getAssetUrl for manifest assets to avoid undefined NEXT_PUBLIC_BASE_PATH

* Add validation warning for NEXT_PUBLIC_BASE_PATH format

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:28:55 +09:00
pointerhacker
9e9ea10beb fix:feature/sglang-provider (#302)
Co-authored-by: zhaochaojin <zhaochaojin@didiglobal.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:13:45 +09:00
Biki Kalita
deae5c2c38 Fix: Localize TPM rate-limit toast via i18n (#353)
* TMP error toast hardcoded english fixed

* fix: correct JA/ZH translations to use tokens instead of requests

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 23:00:20 +09:00
Twelveeee
6e2d98e52d move Language Selector into SettingDialog (#352)
* fix:custom model setting bug

* refactor: consolidate aiProvider checks for cleaner code

* fix:Integrated the language selection option into the `SettingsDialog`

* fix:useSearchParams() should be wrapped in a suspense boundary at page

* fix: improve semantic HTML and maintainability

- Replace nested button>a with proper anchor element for GitHub link
- Use i18n.locales.map() with LANGUAGE_LABELS for language options

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 22:54:25 +09:00
Dayuan Jiang
85cb441e26 feat: multi-provider model configuration with UI/UX improvements (#355)
* feat: add multi-provider model configuration

- Add model config dialog for managing multiple AI providers
- Support for OpenAI, Anthropic, Google, Azure, Bedrock, OpenRouter, DeepSeek, SiliconFlow, Ollama, and AI Gateway
- Add model selector dropdown in chat panel header
- Add API key validation endpoint
- Add custom model ID input with keyboard navigation
- Fix hover highlight in Command component
- Add suggested models for each provider including latest Claude 4.5 series
- Store configuration locally in browser

* feat: improve model config UI and move selector to chat input

- Move model selector from header to chat input (left of send button)
- Add per-model validation status (queued, running, valid, invalid)
- Filter model selector to only show verified models
- Add editable model IDs in config dialog
- Add custom model input field alongside suggested models dropdown
- Fix hover states on provider buttons and select triggers
- Update OpenAI suggested models with GPT-5 series
- Add alert-dialog component for delete confirmation

* refactor: revert shadcn component changes, apply hover fix at usage site

* feat: add AWS credentials support for Bedrock provider

- Add AWS Access Key ID, Secret Access Key, Region fields for Bedrock
- Show different credential fields based on provider type
- Update validation API to handle Bedrock with AWS credentials
- Add region selector with common AWS regions

* fix: reset Test button after validation completes

* fix: reset validation button to Test after success

* fix: complete bedrock support and UI/UX improvements

- Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials
- Pass AWS credentials through full chain (headers → API → provider)
- Replace non-existent GPT-5 models with real ones (o1, o3-mini)
- Add accessibility: aria-labels, focus-visible rings, inline errors
- Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo)
- Fix setTimeout cleanup with useRef on component unmount
- Fix TypeScript type consistency in getSelectedAIConfig fallback

* chore: remove unused code

- Remove unused setAccessCodeRequired state in chat-panel.tsx
- Remove unused getSelectedModel export in model-config.ts

* fix: UI/UX improvements for model configuration dialog

- Add gradient header styling with icon badge
- Change Configuration section icon from Key to Settings2
- Add duplicate model detection with warning banner and inline removal
- Filter out already-added models from suggestions dropdown
- Add type-to-confirm for deleting providers with 3+ models
- Enhance delete confirmation dialog with warning icon
- Improve model selector discoverability (show model name + chevron)
- Add truncation for long model names with title tooltip
- Remove AI provider settings from Settings dialog (now in Model Config)
- Extract ValidationButton into reusable component

* fix: prevent duplicate model IDs within same provider

- Block adding model if ID already exists in provider
- Block editing model ID to match existing model in provider

* fix: improve duplicate model ID notifications

- Add toast notification when trying to add duplicate model
- Allow free typing when editing model ID, validate on blur
- Show warning toast instead of blocking input

* fix: improve duplicate model validation UX in config dialog

- Add inline error display for duplicate model IDs
- Show red border on input when error exists
- Validate on blur with shake animation for edit errors
- Prevent saving empty model names
- Clear errors when user starts typing
- Simplify error styling (small red text, no heavy chips)
2025-12-22 22:36:36 +09:00
Dayuan Jiang
b088a0653e chore: update app icons with new diagram hierarchy design (#350) 2025-12-22 13:24:08 +09:00
Dayuan Jiang
b25b944600 fix(ci): simplify electron release - let electron-builder publish directly (#349) 2025-12-22 11:36:38 +09:00
Dayuan Jiang
4f07a5fafc fix: add write permissions to electron build jobs (#347) 2025-12-22 11:08:03 +09:00
Dayuan Jiang
fc5eca877a chore: bump version to 0.4.5 (#346)
* chore: bump version to 0.4.5 and add desktop app to README

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-22 10:39:28 +09:00
chouheiwa
f58274bb84 feat(electron): add desktop application support with electron (#344)
* feat(electron): add desktop application support with electron

- implement complete Electron main process architecture with window management,
  app menu, IPC handlers, and settings window
- integrate Next.js server for production builds with embedded standalone server
- add configuration management with persistent storage and env file support
- create preload scripts with secure context bridge for renderer communication
- set up electron-builder configuration for multi-platform packaging (macOS,
  Windows, Linux)
- add GitHub Actions workflow for automated release builds
- include development scripts for hot-reload during Electron development

* feat(electron): enhance security and stability

- encrypt API keys using Electron safeStorage API before persisting to disk
- add error handling and rollback for preset switching failures
- extract inline styles to external CSS file and remove unsafe-inline from CSP
- implement dynamic port allocation with automatic fallback for production builds

* fix(electron): add maintainer field for Linux .deb package

- add maintainer email to linux configuration in electron-builder.yml
- required for building .deb packages

* fix(electron): use shx for cross-platform file copying

- replace Unix-only cp -r with npx shx cp -r
- add shx as devDependency for Windows compatibility

* fix(electron): fix runtime icon path for all platforms

- use icon.png directly instead of platform-specific formats
- electron-builder handles icon conversion during packaging
- macOS uses embedded icon from app bundle, no explicit path needed
- add icon.png to extraResources for Windows/Linux runtime access

* fix(electron): add security warning for plaintext API key storage

- warn user when safeStorage is unavailable (Linux without keyring)
- fail secure: throw error if encryption fails instead of storing plaintext
- prevent duplicate warnings with hasWarnedAboutPlaintext flag

* fix(electron): add remaining review fixes

- Add Windows ARM64 architecture support
- Add IPC input validation with config key whitelist
- Add server.js existence check before starting Next.js server
- Make afterPack throw error on missing directories
- Add workflow permissions for release job

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-22 10:18:21 +09:00
Dayuan Jiang
e03b65328d chore(mcp): bump version to 0.1.5 (#343) 2025-12-21 19:44:01 +09:00
Dayuan Jiang
14c1aa8e1c fix(mcp): sync browser state before get_diagram to prevent data loss (#342)
* fix(mcp): sync browser state before get_diagram to prevent data loss

- Add syncRequested flag to SessionState for browser sync coordination
- Add requestSync() and waitForSync() functions to http-server
- Browser polls for syncRequested flag and immediately pushes current state
- get_diagram now syncs fresh state from browser before returning
- edit_diagram requires get_diagram to be called within 30s to prevent stale edits
- Updated edit_diagram description to enforce workflow

* fix(mcp): make lastGetDiagramTime session-scoped and handle missing session in requestSync

- Move lastGetDiagramTime into currentSession object to prevent cross-session issues
- requestSync now returns boolean indicating if request was made
- Only wait for sync if session exists (avoids false-positive from undefined state)
2025-12-21 19:38:35 +09:00
Dayuan Jiang
9e651a51e6 Merge pull request #341 from DayuanJiang/feat/mcp-history
feat(mcp): add diagram version history with SVG previews
2025-12-21 18:08:14 +09:00
dayuan.jiang
2871265362 docs(mcp): add version history feature to README 2025-12-21 18:07:29 +09:00
dayuan.jiang
9d13bd7451 chore(mcp): bump version to 0.1.4 2025-12-21 18:05:44 +09:00
dayuan.jiang
b97f3ccda9 fix(mcp): minimal history integration in index.ts
Keep only essential history integration:
- Import addHistory from history.js
- Remove unused getServerPort import
- Add browser state sync and history saving in display_diagram
- Add history saving in edit_diagram

No changes to prompts, descriptions, or code style.
2025-12-21 17:41:27 +09:00
dayuan.jiang
864375b8e4 fix(mcp): capture SVG for AI-generated diagrams
- Sync browser state before saving history in display_diagram
- Save AI result to history (in addition to state before)
- Add SVG capture after browser loads AI diagrams
- Add /api/history-svg endpoint to update last entry's SVG
- Add updateLastHistorySvg() function to history module
2025-12-21 17:31:06 +09:00
dayuan.jiang
b9bc2a72c6 refactor(mcp): simplify history implementation
- Reduce history.ts from 169 to 51 lines
- Remove AI tools (list_history, restore_version, get_version)
- Remove /api/update-svg endpoint
- Remove 10-second history polling
- Simplify HistoryEntry to just {xml, svg}
- Use array index instead of version numbers

Total reduction: 1936 → 923 lines (-52%)
2025-12-21 16:11:49 +09:00
dayuan.jiang
c215d80688 feat(mcp): add diagram version history
- Add history.ts module with circular buffer (max 50 entries)
- Add history UI with floating button and modal
- Add HTTP endpoints: /api/history, /api/restore
- Add MCP tools: list_history, restore_version, get_version
- Save history before and after AI changes
- Track source (ai/human) for each entry
2025-12-21 16:09:14 +09:00
Dayuan Jiang
74b9e38114 chore: bump version to 0.4.4 (#338)
* chore: bump version to 0.4.4

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-21 01:01:59 +09:00
Dayuan Jiang
68ea4958b8 feat: display app version in Settings dialog (#337) 2025-12-21 00:55:54 +09:00
Dayuan Jiang
938faff6b2 feat(mcp): add XML validation and auto-fix to MCP server (#336)
* feat(mcp): add XML validation and auto-fix to MCP server

- Add xml-validation.ts with validateAndFixXml function
- Integrate validation into display_diagram tool (fails if unfixable)
- Integrate validation into edit_diagram tool (auto-fix each operation)
- Fix bug: typo fixes now run before foreign tag removal
- Fix bug: use before/after comparison instead of regex .test()

* style: auto-format with Biome

* chore(mcp): bump version to 0.1.3

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-21 00:32:51 +09:00
71 changed files with 13735 additions and 1051 deletions

View File

@@ -63,6 +63,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
# Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials

46
.github/workflows/electron-release.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Electron Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g., v0.4.5)"
required: false
jobs:
build:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

13
.gitignore vendored
View File

@@ -50,3 +50,16 @@ push-via-ec2.sh
.wrangler/
.env*.local
# Electron
/dist-electron/
/release/
/electron-standalone/
*.dmg
*.exe
*.AppImage
*.deb
*.rpm
*.snap
CLAUDE.md
.spec-workflow

View File

@@ -26,6 +26,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build Next.js application (standalone mode)
RUN npm run build

View File

@@ -33,6 +33,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [MCP Server (Preview)](#mcp-server-preview)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
- [Desktop Application](#desktop-application)
- [Run with Docker (Recommended)](#run-with-docker-recommended)
- [Installation](#installation)
- [Deployment](#deployment)
@@ -135,6 +136,28 @@ No installation needed! Try the app directly on our demo site:
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
### Desktop Application
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
| Platform | Download |
|----------|----------|
| macOS | `.dmg` (Intel & Apple Silicon) |
| Windows | `.exe` installer (x64 & ARM64) |
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
**Features:**
- **Secure API key storage**: Credentials encrypted using OS keychain
- **Configuration presets**: Save and switch between AI providers via menu
- **Native file dialogs**: Open/save `.drawio` files directly
- **Offline capable**: Works without internet after first launch
**Quick Setup:**
1. Download and install for your platform
2. Open the app → **Menu → Configuration → Manage Presets**
3. Add your AI provider credentials
4. Start creating diagrams!
### Run with Docker (Recommended)
If you just want to run it locally, the best way is to use Docker.

View File

@@ -117,9 +117,9 @@ export default function AboutCN() {
(TPS/TPM)
</p>
<p>
使 Claude {" "}
使 Opus 4.5 {" "}
<span className="font-semibold text-amber-700">
minimax-m2
Haiku 4.5
</span>
</p>

View File

@@ -126,9 +126,9 @@ export default function AboutJA() {
</p>
<p>
Claude {" "}
Opus 4.5 {" "}
<span className="font-semibold text-amber-700">
minimax-m2
Haiku 4.5
</span>{" "}
</p>

View File

@@ -129,9 +129,9 @@ export default function About() {
</p>
<p>
Due to the high usage, I have changed the
model from Claude to{" "}
model from Opus 4.5 to{" "}
<span className="font-semibold text-amber-700">
minimax-m2
Haiku 4.5
</span>
, which is more cost-effective.
</p>

View File

@@ -173,9 +173,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined
// Extract user input text for Langfuse trace
const lastMessage = messages[messages.length - 1]
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages]
.reverse()
.find((m: any) => m.role === "user")
const userInputText =
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user
setTraceInput({
@@ -214,6 +217,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrl: req.headers.get("x-ai-base-url"),
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
// AWS Bedrock credentials
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"),
}
// Read minimal style preference from header
@@ -232,9 +240,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle)
// Extract file parts (images) from the last message
// Extract file parts (images) from the last user message
const fileParts =
lastMessage.parts?.filter((part: any) => part.type === "file") || []
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
@@ -243,7 +252,7 @@ ${userInputText}
"""`
// Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages)
const modelMessages = await convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
@@ -681,7 +690,8 @@ Call this tool to get shape names and usage syntax for a specific library.`,
// Total input = non-cached + cached (these are separate counts)
// Note: cacheWriteInputTokens is not available on finish part
const totalInputTokens =
(usage.inputTokens ?? 0) + (usage.cachedInputTokens ?? 0)
(usage.inputTokens ?? 0) +
(usage.inputTokenDetails?.cacheReadTokens ?? 0)
return {
inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0,

View File

@@ -0,0 +1,281 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest {
provider: string
apiKey: string
baseUrl?: string
modelId: string
// AWS Bedrock specific
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
}
export async function POST(req: Request) {
try {
const body: ValidateRequest = await req.json()
const {
provider,
apiKey,
baseUrl,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
} = body
if (!provider || !modelId) {
return NextResponse.json(
{ valid: false, error: "Provider and model ID are required" },
{ status: 400 },
)
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && !apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
let model: any
switch (provider) {
case "openai": {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openai.chat(modelId)
break
}
case "anthropic": {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
})
model = anthropic(modelId)
break
}
case "google": {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = google(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = azure.chat(modelId)
break
}
case "bedrock": {
const bedrock = createAmazonBedrock({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
region: awsRegion,
})
model = bedrock(modelId)
break
}
case "openrouter": {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openrouter(modelId)
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = ds(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.com/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
const ollama = createOllama({
baseURL: baseUrl || "http://localhost:11434",
})
model = ollama(modelId)
break
}
case "gateway": {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = gw(modelId)
break
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
)
}
// Make a minimal test request
const startTime = Date.now()
await generateText({
model,
prompt: "Say 'OK'",
maxOutputTokens: 20,
})
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
})
} catch (error) {
console.error("[validate-model] Error:", error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
return NextResponse.json(
{ valid: false, error: errorMessage },
{ status: 200 }, // Return 200 so client can read error message
)
}
}

View File

@@ -1,24 +1,24 @@
import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Next AI Draw.io",
short_name: "AIDraw.io",
description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
start_url: "/",
start_url: getAssetUrl("/"),
display: "standalone",
background_color: "#f9fafb",
theme_color: "#171d26",
icons: [
{
src: "/favicon-192x192.png",
src: getAssetUrl("/favicon-192x192.png"),
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/favicon-512x512.png",
src: getAssetUrl("/favicon-512x512.png"),
sizes: "512x512",
type: "image/png",
purpose: "any",

View File

@@ -0,0 +1,156 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
export type ModelSelectorProps = ComponentProps<typeof Dialog>
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
)
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
)
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
)
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
)
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
)
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
)
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider: string
}
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => {
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
if (provider === "amazon-bedrock") {
return <Cloud className={cn("size-4", className)} />
}
return (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
/>
)
}
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className,
)}
{...props}
/>
)
export type ModelSelectorNameProps = ComponentProps<"span">
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)

View File

@@ -9,6 +9,7 @@ import {
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
@@ -79,7 +80,7 @@ export default function ExamplePanel({
setInput("Replicate this flowchart.")
try {
const response = await fetch("/example.png")
const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
@@ -92,7 +93,7 @@ export default function ExamplePanel({
setInput("Replicate this in aws style")
try {
const response = await fetch("/architecture.png")
const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
@@ -107,7 +108,7 @@ export default function ExamplePanel({
setInput("Summarize this paper as a diagram")
try {
const response = await fetch("/chain-of-thought.txt")
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",

View File

@@ -14,6 +14,7 @@ import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
@@ -28,6 +29,7 @@ import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import type { FlattenedModel } from "@/lib/types/model-config"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -156,6 +158,11 @@ interface ChatInputProps {
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
}
export function ChatInput({
@@ -173,6 +180,10 @@ export function ChatInput({
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
@@ -465,6 +476,14 @@ export function ChatInput({
disabled={isDisabled}
/>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button

View File

@@ -27,6 +27,7 @@ import {
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
@@ -291,7 +292,7 @@ export function ChatMessageDisplay({
setFeedback((prev) => ({ ...prev, [messageId]: value }))
try {
await fetch("/api/log-feedback", {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({

View File

@@ -18,18 +18,24 @@ import { FaGithub } from "react-icons/fa"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAIConfig } from "@/lib/ai-config"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -70,6 +76,7 @@ interface ChatPanelProps {
const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
/**
* Check if auto-resubmit should happen based on tool errors.
@@ -146,7 +153,10 @@ export default function ChatPanel({
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [, setAccessCodeRequired] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
// Model configuration hook
const modelConfig = useModelConfig()
const [input, setInput] = useState("")
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
@@ -164,15 +174,14 @@ export default function ChatPanel({
// Check config on mount
useEffect(() => {
fetch("/api/config")
fetch(getApiEndpoint("/api/config"))
.then((res) => res.json())
.then((data) => {
setAccessCodeRequired(data.accessCodeRequired)
setDailyRequestLimit(data.dailyRequestLimit || 0)
setDailyTokenLimit(data.dailyTokenLimit || 0)
setTpmLimit(data.tpmLimit || 0)
})
.catch(() => setAccessCodeRequired(false))
.catch(() => {})
}, [])
// Quota management using extracted hook
@@ -208,6 +217,8 @@ export default function ChatPanel({
// Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling)
const continuationRetryCountRef = useRef(0)
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
// When partialXmlRef.current.length > 0, we're in continuation mode
@@ -236,7 +247,7 @@ export default function ChatPanel({
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
api: getApiEndpoint("/api/chat"),
}),
async onToolCall({ toolCall }) {
if (DEBUG) {
@@ -609,8 +620,7 @@ Continue from EXACTLY where you stopped.`,
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true)
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
@@ -649,15 +659,25 @@ Continue from EXACTLY where you stopped.`,
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: unlimited retries (truncation continuation, not real errors)
// Server limits to 5 steps via stepCountIs(5)
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
// Don't count against retry limit for continuation
// Quota checks still apply below
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
@@ -677,6 +697,7 @@ Continue from EXACTLY where you stopped.`,
if (!tokenLimitCheck.allowed) {
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
@@ -685,6 +706,7 @@ Continue from EXACTLY where you stopped.`,
if (!tpmCheck.allowed) {
quotaManager.showTPMLimitToast()
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
@@ -1017,9 +1039,10 @@ Continue from EXACTLY where you stopped.`,
) => {
// Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
const config = getAIConfig()
const config = getSelectedAIConfig()
sendMessage(
{ parts },
@@ -1036,6 +1059,20 @@ Continue from EXACTLY where you stopped.`,
"x-ai-api-key": config.aiApiKey,
}),
...(config.aiModel && { "x-ai-model": config.aiModel }),
// AWS Bedrock credentials
...(config.awsAccessKeyId && {
"x-aws-access-key-id": config.awsAccessKeyId,
}),
...(config.awsSecretAccessKey && {
"x-aws-secret-access-key":
config.awsSecretAccessKey,
}),
...(config.awsRegion && {
"x-aws-region": config.awsRegion,
}),
...(config.awsSessionToken && {
"x-aws-session-token": config.awsSessionToken,
}),
}),
...(minimalStyle && {
"x-minimal-style": "true",
@@ -1248,32 +1285,18 @@ Continue from EXACTLY where you stopped.`,
Next AI Drawio
</h1>
</div>
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
{!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
<AlertTriangle className="h-4 w-4" />
</ButtonWithTooltip>
</Link>
)}
About
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
@@ -1288,16 +1311,23 @@ Continue from EXACTLY where you stopped.`,
/>
</ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
variant="ghost"
@@ -1310,7 +1340,6 @@ Continue from EXACTLY where you stopped.`,
/>
</ButtonWithTooltip>
<div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && (
<ButtonWithTooltip
tooltipContent={dict.nav.hidePanel}
@@ -1361,6 +1390,10 @@ Continue from EXACTLY where you stopped.`,
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
onConfigureModels={() => setShowModelConfigDialog(true)}
/>
</footer>
@@ -1374,6 +1407,12 @@ Continue from EXACTLY where you stopped.`,
onToggleDarkMode={onToggleDarkMode}
/>
<ModelConfigDialog
open={showModelConfigDialog}
onOpenChange={setShowModelConfigDialog}
modelConfig={modelConfig}
/>
<ResetWarningModal
open={showNewChatDialog}
onOpenChange={setShowNewChatDialog}

View File

@@ -1,108 +0,0 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
"use client"
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
import { useMemo, useState } from "react"
import {
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure: () => void
disabled?: boolean
}
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
gateway: "vercel",
}
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
): Map<string, { provider: string; models: FlattenedModel[] }> {
const groups = new Map<
string,
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) {
const key = model.providerLabel
const existing = groups.get(key)
if (existing) {
existing.models.push(model)
} else {
groups.set(key, { provider: model.provider, models: [model] })
}
}
return groups
}
export function ModelSelector({
models,
selectedModelId,
onSelect,
onConfigure,
disabled = false,
}: ModelSelectorProps) {
const dict = useDictionary()
const [open, setOpen] = useState(false)
// Only show validated models in the selector
const validatedModels = useMemo(
() => models.filter((m) => m.validated === true),
[models],
)
const groupedModels = useMemo(
() => groupModelsByProvider(validatedModels),
[validatedModels],
)
// Find selected model for display
const selectedModel = useMemo(
() => models.find((m) => m.id === selectedModelId),
[models, selectedModelId],
)
const handleSelect = (value: string) => {
if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
onSelect(undefined)
} else {
onSelect(value)
}
setOpen(false)
}
const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
return (
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
tooltipContent={tooltipContent}
variant="ghost"
size="sm"
disabled={disabled}
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
>
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="text-xs truncate">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<ModelSelectorContent title={dict.modelConfig.selectModel}>
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList>
<ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={providerLabel}
heading={providerLabel}
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() => handleSelect(model.id)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[provider] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{dict.modelConfig.onlyVerifiedShown}
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
)
}

View File

@@ -1,7 +1,8 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useEffect, useState } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -21,6 +22,14 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps {
open: boolean
@@ -35,10 +44,6 @@ interface SettingsDialogProps {
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"
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
function getStoredAccessCodeRequired(): boolean | null {
if (typeof window === "undefined") return null
@@ -47,7 +52,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true"
}
export function SettingsDialog({
function SettingsContent({
open,
onOpenChange,
onCloseProtectionChange,
@@ -57,6 +62,9 @@ export function SettingsDialog({
onToggleDarkMode,
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -64,16 +72,13 @@ export function SettingsDialog({
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [provider, setProvider] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [apiKey, setApiKey] = useState("")
const [modelId, setModelId] = useState("")
const [currentLang, setCurrentLang] = useState("en")
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch("/api/config")
fetch(getApiEndpoint("/api/config"))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
@@ -92,6 +97,17 @@ export function SettingsDialog({
})
}, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => {
if (open) {
const storedCode =
@@ -104,16 +120,22 @@ export function SettingsDialog({
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
// Load AI provider settings
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
setError("")
}
}, [open])
const changeLanguage = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => {
if (!accessCodeRequired) return
@@ -121,12 +143,15 @@ export function SettingsDialog({
setIsVerifying(true)
try {
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
},
})
)
const data = await response.json()
@@ -152,307 +177,166 @@ export function SettingsDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
)}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label>{dict.settings.aiProvider}</Label>
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.aiProviderDescription}
{dict.settings.accessCodeDescription}
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">
{dict.settings.provider}
</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
const actualValue =
value === "default" ? "" : value
setProvider(actualValue)
localStorage.setItem(
STORAGE_AI_PROVIDER_KEY,
actualValue,
)
}}
>
<SelectTrigger id="ai-provider">
<SelectValue
placeholder={
dict.settings.useServerDefault
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{dict.settings.useServerDefault}
</SelectItem>
<SelectItem value="openai">
{dict.providers.openai}
</SelectItem>
<SelectItem value="anthropic">
{dict.providers.anthropic}
</SelectItem>
<SelectItem value="google">
{dict.providers.google}
</SelectItem>
<SelectItem value="azure">
{dict.providers.azure}
</SelectItem>
<SelectItem value="openrouter">
{dict.providers.openrouter}
</SelectItem>
<SelectItem value="deepseek">
{dict.providers.deepseek}
</SelectItem>
<SelectItem value="siliconflow">
{dict.providers.siliconflow}
</SelectItem>
</SelectContent>
</Select>
</div>
{provider && provider !== "default" && (
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
{dict.settings.modelId}
</Label>
<Input
id="ai-model"
value={modelId}
onChange={(e) => {
setModelId(e.target.value)
localStorage.setItem(
STORAGE_AI_MODEL_KEY,
e.target.value,
)
}}
placeholder={
provider === "openai"
? "e.g., gpt-4o"
: provider === "anthropic"
? "e.g., claude-sonnet-4-5"
: provider === "google"
? "e.g., gemini-2.0-flash-exp"
: provider ===
"deepseek"
? "e.g., deepseek-chat"
: dict.settings
.modelId
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
{dict.settings.apiKey}
</Label>
<Input
id="ai-api-key"
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
localStorage.setItem(
STORAGE_AI_API_KEY_KEY,
e.target.value,
)
}}
placeholder={
dict.settings.apiKeyPlaceholder
}
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.overrides}{" "}
{provider === "openai"
? "OPENAI_API_KEY"
: provider === "anthropic"
? "ANTHROPIC_API_KEY"
: provider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: provider === "azure"
? "AZURE_API_KEY"
: provider ===
"openrouter"
? "OPENROUTER_API_KEY"
: provider ===
"deepseek"
? "DEEPSEEK_API_KEY"
: provider ===
"siliconflow"
? "SILICONFLOW_API_KEY"
: "server API key"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
{dict.settings.baseUrl}
</Label>
<Input
id="ai-base-url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value)
localStorage.setItem(
STORAGE_AI_BASE_URL_KEY,
e.target.value,
)
}}
placeholder={
provider === "anthropic"
? "https://api.anthropic.com/v1"
: provider === "siliconflow"
? "https://api.siliconflow.com/v1"
: dict.settings
.customEndpoint
}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
localStorage.removeItem(
STORAGE_AI_PROVIDER_KEY,
)
localStorage.removeItem(
STORAGE_AI_BASE_URL_KEY,
)
localStorage.removeItem(
STORAGE_AI_API_KEY_KEY,
)
localStorage.removeItem(
STORAGE_AI_MODEL_KEY,
)
setProvider("")
setBaseUrl("")
setApiKey("")
setModelId("")
}}
>
{dict.settings.clearSettings}
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="language-select">
{dict.settings.language}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.languageDescription}
</p>
</div>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger id="language-select" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-md">
<div className="h-64 flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
</Dialog>
)
}

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

191
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,191 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
onMouseEnter={(e) => {
// Ensure hover updates selection for visual feedback
const item = e.currentTarget
item.setAttribute("data-selected", "true")
// Deselect siblings
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
siblings?.forEach((sibling) => {
if (sibling !== item) {
sibling.setAttribute("data-selected", "false")
}
})
}}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

48
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
interface DiagramContextType {
@@ -329,7 +330,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
sessionId?: string,
) => {
try {
await fetch("/api/log-save", {
await fetch(getApiEndpoint("/api/log-save"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }),

View File

@@ -7,6 +7,11 @@ services:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]

96
electron-builder.yml Normal file
View File

@@ -0,0 +1,96 @@
appId: com.nextaidrawio.app
productName: Next AI Draw.io
copyright: Copyright © 2024 Next AI Draw.io
electronVersion: 39.2.7
directories:
output: release
buildResources: resources
afterPack: ./scripts/afterPack.cjs
files:
- dist-electron/**/*
- "!node_modules"
asarUnpack:
- "**/*.node"
extraResources:
# Copy prepared standalone directory (includes node_modules)
- from: electron-standalone/
to: standalone/
# Copy icon for runtime use (Windows/Linux)
- from: resources/icon.png
to: icon.png
# macOS configuration
mac:
category: public.app-category.productivity
icon: resources/icon.png
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg:
contents:
- x: 130
y: 220
- x: 410
y: 220
type: link
path: /Applications
window:
width: 540
height: 380
# Windows configuration
win:
icon: resources/icon.png
target:
- target: nsis
arch:
- x64
- arm64
- target: portable
arch:
- x64
- arm64
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
createDesktopShortcut: true
createStartMenuShortcut: true
# Linux configuration
linux:
icon: resources/icon.png
category: Office
maintainer: Next AI Draw.io <nextaidrawio@users.noreply.github.com>
target:
- target: AppImage
arch:
- x64
- arm64
- target: deb
arch:
- x64
- arm64
# Publish configuration (optional)
publish:
provider: github
releaseType: release

74
electron.d.ts vendored Normal file
View File

@@ -0,0 +1,74 @@
/**
* Type declarations for Electron API exposed via preload script
*/
/** Configuration preset interface */
interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/** Result of applying a preset */
interface ApplyPresetResult {
success: boolean
error?: string
env?: Record<string, string>
}
declare global {
interface Window {
/** Main window Electron API */
electronAPI?: {
/** Current platform (darwin, win32, linux) */
platform: NodeJS.Platform
/** Whether running in Electron environment */
isElectron: boolean
/** Get application version */
getVersion: () => Promise<string>
/** Minimize the window */
minimize: () => void
/** Maximize/restore the window */
maximize: () => void
/** Close the window */
close: () => void
/** Open file dialog and return file path */
openFile: () => Promise<string | null>
/** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean>
}
/** Settings window Electron API */
settingsAPI?: {
/** Get all configuration presets */
getPresets: () => Promise<ConfigPreset[]>
/** Get current preset ID */
getCurrentPresetId: () => Promise<string | null>
/** Get current preset */
getCurrentPreset: () => Promise<ConfigPreset | null>
/** Save (create or update) a preset */
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => Promise<ConfigPreset>
/** Delete a preset */
deletePreset: (id: string) => Promise<boolean>
/** Apply a preset (sets environment variables and restarts server) */
applyPreset: (id: string) => Promise<ApplyPresetResult>
/** Close settings window */
close: () => void
}
}
}
export { ConfigPreset, ApplyPresetResult }

241
electron/main/app-menu.ts Normal file
View File

@@ -0,0 +1,241 @@
import {
app,
BrowserWindow,
dialog,
Menu,
type MenuItemConstructorOptions,
shell,
} from "electron"
import {
applyPresetToEnv,
getAllPresets,
getCurrentPresetId,
setCurrentPreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window"
/**
* Build and set the application menu
*/
export function buildAppMenu(): void {
const template = getMenuTemplate()
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
/**
* Rebuild the menu (call this when presets change)
*/
export function rebuildAppMenu(): void {
buildAppMenu()
}
/**
* Get the menu template
*/
function getMenuTemplate(): MenuItemConstructorOptions[] {
const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = []
// macOS app menu
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{
label: "Settings...",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
})
}
// File menu
template.push({
label: "File",
submenu: [
...(isMac
? []
: [
{
label: "Settings",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" } as MenuItemConstructorOptions,
]),
isMac ? { role: "close" } : { role: "quit" },
],
})
// Edit menu
template.push({
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
...(isMac
? [
{
role: "pasteAndMatchStyle",
} as MenuItemConstructorOptions,
{ role: "delete" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions,
]
: [
{ role: "delete" } as MenuItemConstructorOptions,
{ type: "separator" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions,
]),
],
})
// View menu
template.push({
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
})
// Configuration menu with presets
template.push(buildConfigMenu())
// Window menu
template.push({
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [
{ type: "separator" } as MenuItemConstructorOptions,
{ role: "front" } as MenuItemConstructorOptions,
]
: [{ role: "close" } as MenuItemConstructorOptions]),
],
})
// Help menu
template.push({
label: "Help",
submenu: [
{
label: "Documentation",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io",
)
},
},
{
label: "Report Issue",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
)
},
},
],
})
return template
}
/**
* Build the Configuration menu with presets
*/
function buildConfigMenu(): MenuItemConstructorOptions {
const presets = getAllPresets()
const currentPresetId = getCurrentPresetId()
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
label: preset.name,
type: "radio",
checked: preset.id === currentPresetId,
click: async () => {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(preset.id)
if (env) {
try {
await restartNextServer()
rebuildAppMenu() // Rebuild menu to update checkmarks
} catch (error) {
console.error("Failed to restart server:", error)
// Revert to previous preset on failure
if (previousPresetId) {
applyPresetToEnv(previousPresetId)
} else {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
},
}))
return {
label: "Configuration",
submenu: [
...(presetItems.length > 0
? [
{ label: "Switch Preset", enabled: false },
{ type: "separator" } as MenuItemConstructorOptions,
...presetItems,
{ type: "separator" } as MenuItemConstructorOptions,
]
: []),
{
label:
presetItems.length > 0
? "Manage Presets..."
: "Add Configuration Preset...",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
],
}
}

View File

@@ -0,0 +1,460 @@
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
/**
* Fields that contain sensitive data and should be encrypted
*/
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
/**
* Prefix to identify encrypted values
*/
const ENCRYPTED_PREFIX = "encrypted:"
/**
* Check if safeStorage encryption is available
*/
function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable()
}
/**
* Track if we've already warned about plaintext storage
*/
let hasWarnedAboutPlaintext = false
/**
* Encrypt a sensitive value using safeStorage
* Warns if encryption is not available (API key stored in plaintext)
*/
function encryptValue(value: string): string {
if (!value) {
return value
}
if (!isEncryptionAvailable()) {
if (!hasWarnedAboutPlaintext) {
console.warn(
"⚠️ SECURITY WARNING: safeStorage not available. " +
"API keys will be stored in PLAINTEXT. " +
"On Linux, install gnome-keyring or similar for secure storage.",
)
hasWarnedAboutPlaintext = true
}
return value
}
try {
const encrypted = safeStorage.encryptString(value)
return ENCRYPTED_PREFIX + encrypted.toString("base64")
} catch (error) {
console.error("Encryption failed:", error)
// Fail secure: don't store if encryption fails
throw new Error(
"Failed to encrypt API key. Cannot securely store credentials.",
)
}
}
/**
* Decrypt a sensitive value using safeStorage
* Returns the original value if it's not encrypted or decryption fails
*/
function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
return value
}
if (!isEncryptionAvailable()) {
console.warn(
"Cannot decrypt value: safeStorage encryption is not available",
)
return value
}
try {
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
const buffer = Buffer.from(base64Data, "base64")
return safeStorage.decryptString(buffer)
} catch (error) {
console.error("Failed to decrypt value:", error)
return value
}
}
/**
* Encrypt sensitive fields in a config object
*/
function encryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const encrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (encrypted[field]) {
encrypted[field] = encryptValue(encrypted[field] as string)
}
}
return encrypted
}
/**
* Decrypt sensitive fields in a config object
*/
function decryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const decrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (decrypted[field]) {
decrypted[field] = decryptValue(decrypted[field] as string)
}
}
return decrypted
}
/**
* Configuration preset interface
*/
export interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/**
* Configuration file structure
*/
interface ConfigPresetsFile {
version: 1
currentPresetId: string | null
presets: ConfigPreset[]
}
const CONFIG_FILE_NAME = "config-presets.json"
/**
* Get the path to the config file
*/
function getConfigFilePath(): string {
const userDataPath = app.getPath("userData")
return path.join(userDataPath, CONFIG_FILE_NAME)
}
/**
* Load presets from the config file
* Decrypts sensitive fields automatically
*/
export function loadPresets(): ConfigPresetsFile {
const configPath = getConfigFilePath()
if (!existsSync(configPath)) {
return {
version: 1,
currentPresetId: null,
presets: [],
}
}
try {
const content = readFileSync(configPath, "utf-8")
const data = JSON.parse(content) as ConfigPresetsFile
// Decrypt sensitive fields in each preset
data.presets = data.presets.map((preset) => ({
...preset,
config: decryptConfig(preset.config) as ConfigPreset["config"],
}))
return data
} catch (error) {
console.error("Failed to load config presets:", error)
return {
version: 1,
currentPresetId: null,
presets: [],
}
}
}
/**
* Save presets to the config file
* Encrypts sensitive fields automatically
*/
export function savePresets(data: ConfigPresetsFile): void {
const configPath = getConfigFilePath()
const userDataPath = app.getPath("userData")
// Ensure the directory exists
if (!existsSync(userDataPath)) {
mkdirSync(userDataPath, { recursive: true })
}
// Encrypt sensitive fields before saving
const dataToSave: ConfigPresetsFile = {
...data,
presets: data.presets.map((preset) => ({
...preset,
config: encryptConfig(preset.config) as ConfigPreset["config"],
})),
}
try {
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save config presets:", error)
throw error
}
}
/**
* Get all presets
*/
export function getAllPresets(): ConfigPreset[] {
const data = loadPresets()
return data.presets
}
/**
* Get current preset ID
*/
export function getCurrentPresetId(): string | null {
const data = loadPresets()
return data.currentPresetId
}
/**
* Get current preset
*/
export function getCurrentPreset(): ConfigPreset | null {
const data = loadPresets()
if (!data.currentPresetId) {
return null
}
return data.presets.find((p) => p.id === data.currentPresetId) || null
}
/**
* Create a new preset
*/
export function createPreset(
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
): ConfigPreset {
const data = loadPresets()
const now = Date.now()
const newPreset: ConfigPreset = {
id: randomUUID(),
name: preset.name,
config: preset.config,
createdAt: now,
updatedAt: now,
}
data.presets.push(newPreset)
savePresets(data)
return newPreset
}
/**
* Update an existing preset
*/
export function updatePreset(
id: string,
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
): ConfigPreset | null {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return null
}
const updatedPreset: ConfigPreset = {
...data.presets[index],
...updates,
updatedAt: Date.now(),
}
data.presets[index] = updatedPreset
savePresets(data)
return updatedPreset
}
/**
* Delete a preset
*/
export function deletePreset(id: string): boolean {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return false
}
data.presets.splice(index, 1)
// Clear current preset if it was deleted
if (data.currentPresetId === id) {
data.currentPresetId = null
}
savePresets(data)
return true
}
/**
* Set the current preset
*/
export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets()
if (id !== null) {
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return false
}
}
data.currentPresetId = id
savePresets(data)
return true
}
/**
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
*/
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
google: {
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
baseUrl: "GOOGLE_BASE_URL",
},
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
openrouter: {
apiKey: "OPENROUTER_API_KEY",
baseUrl: "OPENROUTER_BASE_URL",
},
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
siliconflow: {
apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_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: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
process.env[providerApiKey] = value
appliedEnv[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
process.env[providerBaseUrl] = value
appliedEnv[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
process.env[key] = value
appliedEnv[key] = value
}
}
}
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Get environment variables from current preset
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
*/
export function getCurrentPresetEnv(): Record<string, string> {
const preset = getCurrentPreset()
if (!preset) {
return {}
}
const env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
env[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
env[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
env[key] = value
}
}
}
return env
}

View File

@@ -0,0 +1,67 @@
import fs from "node:fs"
import path from "node:path"
import { app } from "electron"
/**
* Load environment variables from .env file
* Searches multiple locations in priority order
*/
export function loadEnvFile(): void {
const possiblePaths = [
// Next to the executable (for portable installations)
path.join(path.dirname(app.getPath("exe")), ".env"),
// User data directory (persists across updates)
path.join(app.getPath("userData"), ".env"),
// Development: project root
path.join(app.getAppPath(), ".env.local"),
path.join(app.getAppPath(), ".env"),
]
for (const envPath of possiblePaths) {
if (fs.existsSync(envPath)) {
console.log(`Loading environment from: ${envPath}`)
loadEnvFromFile(envPath)
return
}
}
console.log("No .env file found, using system environment variables")
}
/**
* Parse and load environment variables from a file
*/
function loadEnvFromFile(filePath: string): void {
try {
const content = fs.readFileSync(filePath, "utf-8")
const lines = content.split("\n")
for (const line of lines) {
const trimmed = line.trim()
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith("#")) continue
const equalIndex = trimmed.indexOf("=")
if (equalIndex === -1) continue
const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim()
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
// Don't override existing environment variables
if (!(key in process.env)) {
process.env[key] = value
}
}
} catch (error) {
console.error(`Failed to load env file ${filePath}:`, error)
}
}

105
electron/main/index.ts Normal file
View File

@@ -0,0 +1,105 @@
import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager"
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on("second-instance", () => {
const mainWindow = getMainWindow()
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
// Load environment variables from .env files
loadEnvFile()
// Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) {
process.env[key] = value
}
const isDev = process.env.NODE_ENV === "development"
let serverUrl: string | null = null
app.whenReady().then(async () => {
// Register IPC handlers
registerIpcHandlers()
registerSettingsWindowHandlers()
// Build application menu
buildAppMenu()
try {
if (isDev) {
// Development: use the dev server URL
serverUrl =
process.env.ELECTRON_DEV_URL || "http://localhost:6002"
console.log(`Development mode: connecting to ${serverUrl}`)
} else {
// Production: start Next.js standalone server
serverUrl = await startNextServer()
}
// Create main window
createWindow(serverUrl)
} catch (error) {
console.error("Failed to start application:", error)
dialog.showErrorBox(
"Startup Error",
`Failed to start the application: ${error instanceof Error ? error.message : "Unknown error"}`,
)
app.quit()
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (serverUrl) {
createWindow(serverUrl)
}
}
})
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
stopNextServer()
app.quit()
}
})
app.on("before-quit", () => {
stopNextServer()
})
// Open external links in default browser
app.on("web-contents-created", (_, contents) => {
contents.setWindowOpenHandler(({ url }) => {
// Allow diagrams.net iframe
if (
url.includes("diagrams.net") ||
url.includes("draw.io") ||
url.startsWith("http://localhost")
) {
return { action: "allow" }
}
// Open other links in external browser
if (url.startsWith("http://") || url.startsWith("https://")) {
shell.openExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
})
}

View File

@@ -0,0 +1,212 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import {
applyPresetToEnv,
type ConfigPreset,
createPreset,
deletePreset,
getAllPresets,
getCurrentPreset,
getCurrentPresetId,
setCurrentPreset,
updatePreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
/**
* Allowed configuration keys for presets
* This whitelist prevents arbitrary environment variable injection
*/
const ALLOWED_CONFIG_KEYS = new Set([
"AI_PROVIDER",
"AI_MODEL",
"AI_API_KEY",
"AI_BASE_URL",
"TEMPERATURE",
])
/**
* Sanitize preset config to only include allowed keys
*/
function sanitizePresetConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const sanitized: Record<string, string | undefined> = {}
for (const key of ALLOWED_CONFIG_KEYS) {
if (key in config && typeof config[key] === "string") {
sanitized[key] = config[key]
}
}
return sanitized
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
// ==================== App Info ====================
ipcMain.handle("get-version", () => {
return app.getVersion()
})
// ==================== Window Controls ====================
ipcMain.on("window-minimize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.minimize()
})
ipcMain.on("window-maximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win.unmaximize()
} else {
win?.maximize()
}
})
ipcMain.on("window-close", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.close()
})
// ==================== File Dialogs ====================
ipcMain.handle("dialog-open-file", async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters: [
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
{ name: "All Files", extensions: ["*"] },
],
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
// Read the file content
const fs = await import("node:fs/promises")
try {
const content = await fs.readFile(result.filePaths[0], "utf-8")
return content
} catch (error) {
console.error("Failed to read file:", error)
return null
}
})
ipcMain.handle("dialog-save-file", async (event, data: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return false
const result = await dialog.showSaveDialog(win, {
filters: [
{ name: "Draw.io Files", extensions: ["drawio"] },
{ name: "XML Files", extensions: ["xml"] },
],
})
if (result.canceled || !result.filePath) {
return false
}
const fs = await import("node:fs/promises")
try {
await fs.writeFile(result.filePath, data, "utf-8")
return true
} catch (error) {
console.error("Failed to save file:", error)
return false
}
})
// ==================== Config Presets ====================
ipcMain.handle("config-presets:get-all", () => {
return getAllPresets()
})
ipcMain.handle("config-presets:get-current", () => {
return getCurrentPreset()
})
ipcMain.handle("config-presets:get-current-id", () => {
return getCurrentPresetId()
})
ipcMain.handle(
"config-presets:save",
(
_event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string
},
) => {
// Validate preset name
if (typeof preset.name !== "string" || !preset.name.trim()) {
throw new Error("Invalid preset name")
}
// Sanitize config to only allow whitelisted keys
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
if (preset.id) {
// Update existing preset
return updatePreset(preset.id, {
name: preset.name.trim(),
config: sanitizedConfig,
})
}
// Create new preset
return createPreset({
name: preset.name.trim(),
config: sanitizedConfig,
})
},
)
ipcMain.handle("config-presets:delete", (_event, id: string) => {
return deletePreset(id)
})
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
const env = applyPresetToEnv(id)
if (!env) {
return { success: false, error: "Preset not found" }
}
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development mode, the config file change will trigger
// the file watcher in electron-dev.mjs to restart Next.js
// We just need to save the preset (already done in applyPresetToEnv)
return { success: true, env, devMode: true }
}
// Production mode: restart the Next.js server to apply new environment variables
try {
await restartNextServer()
return { success: true, env }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to restart server",
}
}
})
ipcMain.handle(
"config-presets:set-current",
(_event, id: string | null) => {
return setCurrentPreset(id)
},
)
}

View File

@@ -0,0 +1,161 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { app, type UtilityProcess, utilityProcess } from "electron"
import {
findAvailablePort,
getAllocatedPort,
getServerUrl,
isPortAvailable,
} from "./port-manager"
let serverProcess: UtilityProcess | null = null
/**
* Get the path to the standalone server resources
* In packaged app: resources/standalone
* In development: .next/standalone
*/
function getResourcePath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "standalone")
}
return path.join(app.getAppPath(), ".next", "standalone")
}
/**
* Wait for the server to be ready by polling the health endpoint
*/
async function waitForServer(url: string, timeout = 30000): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const response = await fetch(url)
if (response.ok || response.status < 500) {
return
}
} catch {
// Server not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Server startup timeout after ${timeout}ms`)
}
/**
* Start the Next.js standalone server using Electron's utilityProcess
* This API is designed for running Node.js code in the background
*/
export async function startNextServer(): Promise<string> {
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
console.log(`Starting Next.js server from: ${resourcePath}`)
console.log(`Server script path: ${serverPath}`)
// Verify server script exists before attempting to start
if (!existsSync(serverPath)) {
throw new Error(
`Server script not found at ${serverPath}. ` +
"Please ensure the app was built correctly with 'npm run build'.",
)
}
// Find an available port (random in production, fixed in development)
const port = await findAvailablePort()
console.log(`Using port: ${port}`)
// Set up environment variables
const env: Record<string, string> = {
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "localhost",
}
// Set cache directory to a writable location (user's app data folder)
// This is necessary because the packaged app might be on a read-only volume
if (app.isPackaged) {
const cacheDir = path.join(app.getPath("userData"), "cache")
env.NEXT_CACHE_DIR = cacheDir
}
// Copy existing environment variables
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !env[key]) {
env[key] = value
}
}
// Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron
serverProcess = utilityProcess.fork(serverPath, [], {
cwd: resourcePath,
env,
stdio: "pipe",
})
serverProcess.stdout?.on("data", (data) => {
console.log(`[Next.js] ${data.toString().trim()}`)
})
serverProcess.stderr?.on("data", (data) => {
console.error(`[Next.js Error] ${data.toString().trim()}`)
})
serverProcess.on("exit", (code) => {
console.log(`Next.js server exited with code ${code}`)
serverProcess = null
})
const url = getServerUrl()
await waitForServer(url)
console.log(`Next.js server started at ${url}`)
return url
}
/**
* Stop the Next.js server process
*/
export function stopNextServer(): void {
if (serverProcess) {
console.log("Stopping Next.js server...")
serverProcess.kill()
serverProcess = null
}
}
/**
* Wait for the server to fully stop
*/
async function waitForServerStop(timeout = 5000): Promise<void> {
const port = getAllocatedPort()
if (port === null) {
return
}
const start = Date.now()
while (Date.now() - start < timeout) {
const available = await isPortAvailable(port)
if (available) {
return
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.warn("Server stop timeout, port may still be in use")
}
/**
* Restart the Next.js server with new environment variables
*/
export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...")
// Stop the current server
stopNextServer()
// Wait for the port to be released
await waitForServerStop()
// Start the server again
return startNextServer()
}

View File

@@ -0,0 +1,129 @@
import net from "node:net"
import { app } from "electron"
/**
* Port configuration
*/
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Production mode port range (will find first available)
production: {
min: 10000,
max: 65535,
},
// Maximum attempts to find an available port
maxAttempts: 100,
}
/**
* Currently allocated port (cached after first allocation)
*/
let allocatedPort: number | null = null
/**
* Check if a specific port is available
*/
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", () => resolve(false))
server.once("listening", () => {
server.close()
resolve(true)
})
server.listen(port, "127.0.0.1")
})
}
/**
* Generate a random port within the production range
*/
function getRandomPort(): number {
const { min, max } = PORT_CONFIG.production
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: finds a random available port
* - If a port was previously allocated, verifies it's still available
*
* @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged
// Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) {
const available = await isPortAvailable(allocatedPort)
if (available) {
return allocatedPort
}
console.warn(
`Previously allocated port ${allocatedPort} is no longer available`,
)
allocatedPort = null
}
if (isDev) {
// Development mode: use fixed port
const port = PORT_CONFIG.development
const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
return port
}
console.warn(
`Development port ${port} is in use, finding alternative...`,
)
}
// Production mode or dev port unavailable: find random available port
for (let attempt = 0; attempt < PORT_CONFIG.maxAttempts; attempt++) {
const port = isDev
? PORT_CONFIG.development + attempt + 1
: getRandomPort()
const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
console.log(`Allocated port: ${port}`)
return port
}
}
throw new Error(
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
)
}
/**
* Get the currently allocated port
* Returns null if no port has been allocated yet
*/
export function getAllocatedPort(): number | null {
return allocatedPort
}
/**
* Reset the allocated port (useful for testing or restart scenarios)
*/
export function resetAllocatedPort(): void {
allocatedPort = null
}
/**
* Get the server URL with the allocated port
*/
export function getServerUrl(): string {
if (allocatedPort === null) {
throw new Error(
"No port allocated yet. Call findAvailablePort() first.",
)
}
return `http://localhost:${allocatedPort}`
}

View File

@@ -0,0 +1,78 @@
import path from "node:path"
import { app, BrowserWindow, ipcMain } from "electron"
let settingsWindow: BrowserWindow | null = null
/**
* Create and show the settings window
*/
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
// If settings window already exists, focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus()
return
}
// Determine path to settings preload script
// In compiled output: dist-electron/preload/settings.js
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
// Determine path to settings HTML
// In packaged app: app.asar/dist-electron/settings/index.html
// In development: electron/settings/index.html
const settingsHtmlPath = app.isPackaged
? path.join(__dirname, "..", "settings", "index.html")
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
settingsWindow = new BrowserWindow({
width: 600,
height: 700,
minWidth: 500,
minHeight: 500,
parent: parentWindow,
modal: false,
show: false,
title: "Settings - Next AI Draw.io",
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
settingsWindow.loadFile(settingsHtmlPath)
settingsWindow.once("ready-to-show", () => {
settingsWindow?.show()
})
settingsWindow.on("closed", () => {
settingsWindow = null
})
}
/**
* Close the settings window if it exists
*/
export function closeSettingsWindow(): void {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.close()
settingsWindow = null
}
}
/**
* Check if settings window is open
*/
export function isSettingsWindowOpen(): boolean {
return settingsWindow !== null && !settingsWindow.isDestroyed()
}
/**
* Register settings window IPC handlers
*/
export function registerSettingsWindowHandlers(): void {
ipcMain.on("settings:close", () => {
closeSettingsWindow()
})
}

View File

@@ -0,0 +1,84 @@
import path from "node:path"
import { app, BrowserWindow, screen } from "electron"
let mainWindow: BrowserWindow | null = null
/**
* Get the icon path based on platform
* Note: electron-builder converts icon.png during packaging,
* but at runtime we use PNG directly - Electron handles it
*/
function getIconPath(): string | undefined {
// macOS doesn't need explicit icon - it's embedded in the app bundle
if (process.platform === "darwin" && app.isPackaged) {
return undefined
}
const iconName = "icon.png"
if (app.isPackaged) {
return path.join(process.resourcesPath, iconName)
}
// Development: use icon.png from resources
return path.join(__dirname, "../../resources/icon.png")
}
/**
* Create the main application window
*/
export function createWindow(serverUrl: string): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({
width: Math.min(1400, Math.floor(width * 0.9)),
height: Math.min(900, Math.floor(height * 0.9)),
minWidth: 800,
minHeight: 600,
title: "Next AI Draw.io",
icon: getIconPath(),
show: false, // Don't show until ready
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
})
// Load the Next.js application
mainWindow.loadURL(serverUrl)
// Show window when ready to prevent flashing
mainWindow.once("ready-to-show", () => {
mainWindow?.show()
})
// Open DevTools in development
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools()
}
mainWindow.on("closed", () => {
mainWindow = null
})
// Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => {
if (title && !title.includes("localhost")) {
mainWindow?.setTitle(title)
} else {
event.preventDefault()
}
})
return mainWindow
}
/**
* Get the main window instance
*/
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}

24
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import { contextBridge, ipcRenderer } from "electron"
/**
* Expose safe APIs to the renderer process
*/
contextBridge.exposeInMainWorld("electronAPI", {
// Platform information
platform: process.platform,
// Check if running in Electron
isElectron: true,
// Application version
getVersion: () => ipcRenderer.invoke("get-version"),
// Window controls (optional, for custom title bar)
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
})

View File

@@ -0,0 +1,35 @@
/**
* Preload script for settings window
* Exposes APIs for managing configuration presets
*/
import { contextBridge, ipcRenderer } from "electron"
// Expose settings API to the renderer process
contextBridge.exposeInMainWorld("settingsAPI", {
// Get all presets
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
// Get current preset ID
getCurrentPresetId: () =>
ipcRenderer.invoke("config-presets:get-current-id"),
// Get current preset
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
// Save (create or update) a preset
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => ipcRenderer.invoke("config-presets:save", preset),
// Delete a preset
deletePreset: (id: string) =>
ipcRenderer.invoke("config-presets:delete", id),
// Apply a preset (sets environment variables and restarts server)
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
// Close settings window
close: () => ipcRenderer.send("settings:close"),
})

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
<title>Settings - Next AI Draw.io</title>
<link rel="stylesheet" href="./settings.css">
</head>
<body>
<div class="container">
<h1>Configuration Presets</h1>
<div class="section">
<h2>Presets</h2>
<div id="preset-list" class="preset-list">
<!-- Presets will be loaded here -->
</div>
<button id="add-preset-btn" class="btn btn-primary">
+ Add New Preset
</button>
</div>
</div>
<!-- Add/Edit Preset Modal -->
<div id="preset-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Add Preset</h3>
</div>
<div class="modal-body">
<form id="preset-form">
<input type="hidden" id="preset-id">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
</div>
<div class="form-group">
<label for="ai-provider">AI Provider</label>
<select id="ai-provider">
<option value="">-- Select Provider --</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="google">Google AI (Gemini)</option>
<option value="azure">Azure OpenAI</option>
<option value="bedrock">AWS Bedrock</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
<div class="form-group">
<label for="ai-model">Model ID</label>
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
<div class="hint">The model identifier to use with the selected provider</div>
</div>
<div class="form-group">
<label for="ai-api-key">API Key</label>
<input type="password" id="ai-api-key" placeholder="Your API key">
<div class="hint">This will be stored locally on your device</div>
</div>
<div class="form-group">
<label for="ai-base-url">Base URL (Optional)</label>
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
<div class="hint">Custom API endpoint URL</div>
</div>
<div class="form-group">
<label for="temperature">Temperature (Optional)</label>
<input type="text" id="temperature" placeholder="0.7">
<div class="hint">Controls randomness (0.0 - 2.0)</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Delete Preset</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
<p class="delete-warning">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast"></div>
<script src="./settings.js"></script>
</body>
</html>

View File

@@ -0,0 +1,311 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--border-color: #e0e0e0;
--accent-color: #0066cc;
--accent-hover: #0052a3;
--danger-color: #dc3545;
--success-color: #28a745;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-hover: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #404040;
--accent-color: #4da6ff;
--accent-hover: #66b3ff;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
.container {
max-width: 560px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.section {
margin-bottom: 32px;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.preset-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-card:hover {
background: var(--bg-hover);
}
.preset-card.active {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.preset-name {
font-weight: 600;
font-size: 15px;
}
.preset-badge {
background: var(--accent-color);
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.preset-info {
font-size: 13px;
color: var(--text-secondary);
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin-bottom: 16px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
.form-group .hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--text-primary);
color: var(--bg-primary);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 200;
opacity: 0;
transition: opacity 0.3s ease;
}
.toast.show {
opacity: 1;
}
.toast.success {
background: var(--success-color);
color: white;
}
.toast.error {
background: var(--danger-color);
color: white;
}
/* Inline style replacements */
.delete-warning {
color: var(--text-secondary);
margin-top: 8px;
font-size: 14px;
}

View File

@@ -0,0 +1,311 @@
// Settings page JavaScript
// This file handles the UI interactions for the settings window
let presets = []
let currentPresetId = null
let editingPresetId = null
let deletingPresetId = null
// DOM Elements
const presetList = document.getElementById("preset-list")
const addPresetBtn = document.getElementById("add-preset-btn")
const presetModal = document.getElementById("preset-modal")
const deleteModal = document.getElementById("delete-modal")
const presetForm = document.getElementById("preset-form")
const modalTitle = document.getElementById("modal-title")
const toast = document.getElementById("toast")
// Form fields
const presetIdField = document.getElementById("preset-id")
const presetNameField = document.getElementById("preset-name")
const aiProviderField = document.getElementById("ai-provider")
const aiModelField = document.getElementById("ai-model")
const aiApiKeyField = document.getElementById("ai-api-key")
const aiBaseUrlField = document.getElementById("ai-base-url")
const temperatureField = document.getElementById("temperature")
// Buttons
const cancelBtn = document.getElementById("cancel-btn")
const saveBtn = document.getElementById("save-btn")
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
// Initialize
document.addEventListener("DOMContentLoaded", async () => {
await loadPresets()
setupEventListeners()
})
// Load presets from main process
async function loadPresets() {
try {
presets = await window.settingsAPI.getPresets()
currentPresetId = await window.settingsAPI.getCurrentPresetId()
renderPresets()
} catch (error) {
console.error("Failed to load presets:", error)
showToast("Failed to load presets", "error")
}
}
// Render presets list
function renderPresets() {
if (presets.length === 0) {
presetList.innerHTML = `
<div class="empty-state">
<p>No presets configured yet.</p>
<p>Add a preset to quickly switch between different AI configurations.</p>
</div>
`
return
}
presetList.innerHTML = presets
.map((preset) => {
const isActive = preset.id === currentPresetId
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
return `
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
<div class="preset-header">
<span class="preset-name">${escapeHtml(preset.name)}</span>
${isActive ? '<span class="preset-badge">Active</span>' : ""}
</div>
<div class="preset-info">
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
</div>
<div class="preset-actions">
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
</div>
</div>
`
})
.join("")
// Add event listeners to buttons
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
applyPreset(btn.dataset.id)
})
})
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openEditModal(btn.dataset.id)
})
})
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openDeleteModal(btn.dataset.id)
})
})
}
// Setup event listeners
function setupEventListeners() {
addPresetBtn.addEventListener("click", () => openAddModal())
cancelBtn.addEventListener("click", () => closeModal())
saveBtn.addEventListener("click", () => savePreset())
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
// Close modal on overlay click
presetModal.addEventListener("click", (e) => {
if (e.target === presetModal) closeModal()
})
deleteModal.addEventListener("click", (e) => {
if (e.target === deleteModal) closeDeleteModal()
})
// Handle Enter key in form
presetForm.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault()
savePreset()
}
})
}
// Open add modal
function openAddModal() {
editingPresetId = null
modalTitle.textContent = "Add Preset"
presetForm.reset()
presetIdField.value = ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Open edit modal
function openEditModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
editingPresetId = id
modalTitle.textContent = "Edit Preset"
presetIdField.value = preset.id
presetNameField.value = preset.name
aiProviderField.value = preset.config.AI_PROVIDER || ""
aiModelField.value = preset.config.AI_MODEL || ""
aiApiKeyField.value = preset.config.AI_API_KEY || ""
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
temperatureField.value = preset.config.TEMPERATURE || ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Close modal
function closeModal() {
presetModal.classList.remove("show")
editingPresetId = null
}
// Open delete modal
function openDeleteModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
deletingPresetId = id
document.getElementById("delete-preset-name").textContent = preset.name
deleteModal.classList.add("show")
}
// Close delete modal
function closeDeleteModal() {
deleteModal.classList.remove("show")
deletingPresetId = null
}
// Save preset
async function savePreset() {
const name = presetNameField.value.trim()
if (!name) {
showToast("Please enter a preset name", "error")
presetNameField.focus()
return
}
const preset = {
id: editingPresetId || undefined,
name: name,
config: {
AI_PROVIDER: aiProviderField.value || undefined,
AI_MODEL: aiModelField.value.trim() || undefined,
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
TEMPERATURE: temperatureField.value.trim() || undefined,
},
}
// Remove undefined values
Object.keys(preset.config).forEach((key) => {
if (preset.config[key] === undefined) {
delete preset.config[key]
}
})
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(
editingPresetId ? "Preset updated" : "Preset created",
"success",
)
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
} finally {
saveBtn.disabled = false
saveBtn.textContent = "Save"
}
}
// Confirm delete
async function confirmDelete() {
if (!deletingPresetId) return
try {
deleteConfirmBtn.disabled = true
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.deletePreset(deletingPresetId)
await loadPresets()
closeDeleteModal()
showToast("Preset deleted", "success")
} catch (error) {
console.error("Failed to delete preset:", error)
showToast("Failed to delete preset", "error")
} finally {
deleteConfirmBtn.disabled = false
deleteConfirmBtn.textContent = "Delete"
}
}
// Apply preset
async function applyPreset(id) {
try {
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
if (btn) {
btn.disabled = true
btn.innerHTML = '<span class="loading"></span>'
}
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
currentPresetId = id
renderPresets()
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
}
} catch (error) {
console.error("Failed to apply preset:", error)
showToast("Failed to apply preset", "error")
}
}
// Get provider display label
function getProviderLabel(provider) {
const labels = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google AI",
azure: "Azure OpenAI",
bedrock: "AWS Bedrock",
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
ollama: "Ollama",
}
return labels[provider] || provider
}
// Show toast notification
function showToast(message, type = "") {
toast.textContent = message
toast.className = "toast show" + (type ? ` ${type}` : "")
setTimeout(() => {
toast.classList.remove("show")
}, 3000)
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}

18
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "../dist-electron",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["./**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -68,6 +68,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# SGLang Configuration (OpenAI-compatible)
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -93,6 +97,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
# Leave empty for root deployment (default)
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.

373
hooks/use-model-config.ts Normal file
View File

@@ -0,0 +1,373 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { STORAGE_KEYS } from "@/lib/storage"
import {
createEmptyConfig,
createModelConfig,
createProviderConfig,
type FlattenedModel,
findModelById,
flattenModels,
type ModelConfig,
type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"
// Old storage keys for migration
const OLD_KEYS = {
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
}
/**
* Migrate from old single-provider format to new multi-model format
*/
function migrateOldConfig(): MultiModelConfig | null {
if (typeof window === "undefined") return null
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate
if (!oldProvider || !oldApiKey || !oldModel) return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
const model = createModelConfig(oldModel)
provider.models.push(model)
const config: MultiModelConfig = {
version: 1,
providers: [provider],
selectedModelId: model.id,
}
// Clear old keys after migration
localStorage.removeItem(OLD_KEYS.aiProvider)
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
localStorage.removeItem(OLD_KEYS.aiApiKey)
localStorage.removeItem(OLD_KEYS.aiModel)
return config
}
/**
* Load config from localStorage
*/
function loadConfig(): MultiModelConfig {
if (typeof window === "undefined") return createEmptyConfig()
// First, check if new format exists
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) {
try {
return JSON.parse(stored) as MultiModelConfig
} catch {
console.error("Failed to parse model config")
}
}
// Try migration from old format
const migrated = migrateOldConfig()
if (migrated) {
// Save migrated config
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify(migrated),
)
return migrated
}
return createEmptyConfig()
}
/**
* Save config to localStorage
*/
function saveConfig(config: MultiModelConfig): void {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
export interface UseModelConfigReturn {
// State
config: MultiModelConfig
isLoaded: boolean
// Getters
models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined
// Actions
setSelectedModelId: (modelId: string | undefined) => void
addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: (
providerId: string,
updates: Partial<ProviderConfig>,
) => void
deleteProvider: (providerId: string) => void
addModel: (providerId: string, modelId: string) => ModelConfig
updateModel: (
providerId: string,
modelConfigId: string,
updates: Partial<ModelConfig>,
) => void
deleteModel: (providerId: string, modelConfigId: string) => void
resetConfig: () => void
}
export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
const [isLoaded, setIsLoaded] = useState(false)
// Load config on mount
useEffect(() => {
const loaded = loadConfig()
setConfig(loaded)
setIsLoaded(true)
}, [])
// Save config whenever it changes (after initial load)
useEffect(() => {
if (isLoaded) {
saveConfig(config)
}
}, [config, isLoaded])
// Derived state
const models = flattenModels(config)
const selectedModel = config.selectedModelId
? findModelById(config, config.selectedModelId)
: undefined
// Actions
const setSelectedModelId = useCallback((modelId: string | undefined) => {
setConfig((prev) => ({
...prev,
selectedModelId: modelId,
}))
}, [])
const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider)
setConfig((prev) => ({
...prev,
providers: [...prev.providers, newProvider],
}))
return newProvider
},
[],
)
const updateProvider = useCallback(
(providerId: string, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId ? { ...p, ...updates } : p,
),
}))
},
[],
)
const deleteProvider = useCallback((providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
// Clear selected model if it belongs to deleted provider
const newSelectedId =
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
? undefined
: prev.selectedModelId
return {
...prev,
providers: prev.providers.filter((p) => p.id !== providerId),
selectedModelId: newSelectedId,
}
})
}, [])
const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => {
const newModel = createModelConfig(modelId)
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? { ...p, models: [...p.models, newModel] }
: p,
),
}))
return newModel
},
[],
)
const updateModel = useCallback(
(
providerId: string,
modelConfigId: string,
updates: Partial<ModelConfig>,
) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? {
...p,
models: p.models.map((m) =>
m.id === modelConfigId
? { ...m, ...updates }
: m,
),
}
: p,
),
}))
},
[],
)
const deleteModel = useCallback(
(providerId: string, modelConfigId: string) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? {
...p,
models: p.models.filter(
(m) => m.id !== modelConfigId,
),
}
: p,
),
// Clear selected model if it was deleted
selectedModelId:
prev.selectedModelId === modelConfigId
? undefined
: prev.selectedModelId,
}))
},
[],
)
const resetConfig = useCallback(() => {
setConfig(createEmptyConfig())
}, [])
return {
config,
isLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
setSelectedModelId,
addProvider,
updateProvider,
deleteProvider,
addModel,
updateModel,
deleteModel,
resetConfig,
}
}
/**
* Get the AI config for the currently selected model.
* Returns format compatible with existing getAIConfig() usage.
*/
export function getSelectedAIConfig(): {
accessCode: string
aiProvider: string
aiBaseUrl: string
aiApiKey: string
aiModel: string
// AWS Bedrock credentials
awsAccessKeyId: string
awsSecretAccessKey: string
awsRegion: string
awsSessionToken: string
} {
const empty = {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
}
if (typeof window === "undefined") return empty
// Get access code (separate from model config)
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
// Load multi-model config
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (!stored) {
// Fallback to old format for backward compatibility
return {
accessCode,
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
// Old format didn't support AWS
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
}
}
let config: MultiModelConfig
try {
config = JSON.parse(stored)
} catch {
return { ...empty, accessCode }
}
// No selected model = use server default
if (!config.selectedModelId) {
return { ...empty, accessCode }
}
// Find selected model
const model = findModelById(config, config.selectedModelId)
if (!model) {
return { ...empty, accessCode }
}
return {
accessCode,
aiProvider: model.provider,
aiBaseUrl: model.baseUrl || "",
aiApiKey: model.apiKey,
aiModel: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: model.awsAccessKeyId || "",
awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "",
}
}

View File

@@ -19,10 +19,13 @@ export function register() {
const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans
if (
spanName.startsWith("POST /") ||
spanName.startsWith("GET /") ||
spanName.startsWith("POST") ||
spanName.startsWith("GET") ||
spanName.startsWith("RSC") ||
spanName.includes("BaseServer") ||
spanName.includes("handleRequest")
spanName.includes("handleRequest") ||
spanName.includes("resolve page") ||
spanName.includes("start response")
) {
return false
}
@@ -36,4 +39,5 @@ export function register() {
// Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register()
console.log("[Langfuse] Instrumentation initialized successfully")
}

View File

@@ -19,6 +19,7 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
interface ModelConfig {
@@ -33,6 +34,11 @@ export interface ClientOverrides {
baseUrl?: string | null
apiKey?: string | null
modelId?: string | null
// AWS Bedrock credentials
awsAccessKeyId?: string | null
awsSecretAccessKey?: string | null
awsRegion?: string | null
awsSessionToken?: string | null
}
// Providers that can be used with client-provided API keys
@@ -41,9 +47,11 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"anthropic",
"google",
"azure",
"bedrock",
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
]
@@ -87,8 +95,8 @@ function parseIntSafe(
* Supports various AI SDK providers with their unique configuration options
*
* Environment variables:
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
@@ -110,18 +118,19 @@ function buildProviderOptions(
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
// OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts
if (
modelId &&
(modelId.includes("o1") ||
modelId.includes("o3") ||
modelId.includes("o4") ||
modelId.includes("gpt-5"))
) {
options.openai = {
// Auto-enable reasoning summary for reasoning models (default: detailed)
// Auto-enable reasoning summary for reasoning models
// Use 'auto' as default since not all models support 'detailed'
reasoningSummary:
(reasoningSummary as "none" | "brief" | "detailed") ||
"detailed",
(reasoningSummary as "auto" | "detailed") || "auto",
}
// Optionally configure reasoning effort
@@ -144,8 +153,7 @@ function buildProviderOptions(
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "none"
| "brief"
| "auto"
| "detailed"
}
}
@@ -337,6 +345,7 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "siliconflow":
case "sglang":
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
@@ -361,6 +370,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
@@ -426,7 +436,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -442,6 +452,8 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
*/
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -510,6 +522,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -537,12 +550,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
switch (provider) {
case "bedrock": {
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || "us-west-2",
credentialProvider: fromNodeProviderChain(),
})
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
const hasClientCredentials =
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
const bedrockRegion =
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
const bedrockProvider = hasClientCredentials
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey!,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
})
: createAmazonBedrock({
region: bedrockRegion,
credentialProvider: fromNodeProviderChain(),
})
model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) {
@@ -567,7 +593,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
apiKey,
...(baseURL && { baseURL }),
})
model = customOpenAI.chat(modelId)
// Use Responses API (default) instead of .chat() to support reasoning
// for gpt-5, o1, o3, o4 models. Chat Completions API does not emit reasoning events.
model = customOpenAI(modelId)
} else {
model = openai(modelId)
}
@@ -679,6 +707,112 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
if (!response.body) {
return response
}
// Create a transform stream to fix the non-compliant sglang stream
let buffer = ""
const decoder = new TextDecoder()
const transformStream = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
// Process all complete messages in the buffer
let messageEndPos
while (
(messageEndPos = buffer.indexOf("\n\n")) !== -1
) {
const message = buffer.substring(
0,
messageEndPos,
)
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
if (message.startsWith("data: ")) {
const jsonStr = message.substring(6).trim()
if (jsonStr === "[DONE]") {
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
continue
}
try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
if (delta) {
// Fix 1: remove invalid empty role
if (delta.role === "") {
delete delta.role
}
// Fix 2: remove non-standard reasoning_content field
if ("reasoning_content" in delta) {
delete delta.reasoning_content
}
}
// Re-serialize and forward the corrected data with the correct SSE format
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
} else if (message.trim() !== "") {
// Pass through other message types (e.g., 'event: ...')
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
}
},
flush(controller) {
// If there's anything left in the buffer, forward it.
if (buffer.trim()) {
controller.enqueue(
new TextEncoder().encode(buffer),
)
}
},
})
const transformedBody =
response.body.pipeThrough(transformStream)
// Return a new response with the transformed body
return new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
},
})
model = sglangProvider.chat(modelId)
break
}
case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -702,7 +836,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
)
}

37
lib/base-path.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Get the base path for API calls and static assets
* This is used for subdirectory deployment support
*
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
* For root deployment, this returns ""
*
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
*/
export function getBasePath(): string {
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
if (basePath && !basePath.startsWith("/")) {
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
}
return basePath
}
/**
* Get full API endpoint URL
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
* @returns Full API path with base path prefix
*/
export function getApiEndpoint(endpoint: string): string {
const basePath = getBasePath()
return `${basePath}${endpoint}`
}
/**
* Get full static asset URL
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
* @returns Full asset path with base path prefix
*/
export function getAssetUrl(assetPath: string): string {
const basePath = getBasePath()
return `${basePath}${assetPath}`
}

View File

@@ -14,6 +14,7 @@
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
@@ -147,6 +150,7 @@
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
@@ -180,5 +184,65 @@
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"modelConfig": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",
"configure": "Configure",
"addProvider": "Add Provider",
"addModel": "Add Model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete Provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"selectProvider": "Select a provider or add a new one",
"configureMultiple": "Configure multiple AI providers and switch between them easily",
"apiKeyStored": "API keys are stored locally in your browser",
"test": "Test",
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate",
"providers": "Providers",
"addProviderHint": "Add a provider to get started",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display Name",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
"selectRegion": "Select region",
"apiKey": "API Key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"customEndpoint": "Custom endpoint URL",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",
"suggested": "Suggested",
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"configureProviders": "Configure AI Providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type provider name...",
"modelsConfiguredCount": "{count} model(s) configured",
"validationFailedCount": "{count} model(s) failed validation",
"cancel": "Cancel",
"delete": "Delete",
"clickToChange": "(click to change)",
"usingServerDefault": "Using server default model",
"selectModel": "Select Model",
"searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown"
}
}

View File

@@ -14,6 +14,7 @@
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
@@ -147,6 +150,7 @@
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
@@ -180,5 +184,65 @@
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"modelConfig": {
"title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定",
"configure": "設定",
"addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
"modelId": "モデルID",
"modelLabel": "表示名",
"streaming": "ストリーミングを有効",
"deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。",
"selectProvider": "プロバイダーを選択または追加してください",
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
"test": "テスト",
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーを追加して開始",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
"selectRegion": "リージョンを選択",
"apiKey": "API キー",
"enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
"suggested": "おすすめ",
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーを設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
"validationFailedCount": "{count} 個のモデルの検証に失敗",
"cancel": "キャンセル",
"delete": "削除",
"clickToChange": "(クリックして変更)",
"usingServerDefault": "サーバーデフォルトモデルを使用中",
"selectModel": "モデルを選択",
"searchModels": "モデルを検索...",
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示"
}
}

View File

@@ -14,6 +14,7 @@
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
@@ -87,6 +88,8 @@
"overrides": "覆盖",
"clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
@@ -147,6 +150,7 @@
"tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
@@ -180,5 +184,65 @@
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
"contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
"configure": "配置",
"addProvider": "添加提供商",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelLabel": "显示名称",
"streaming": "启用流式输出",
"deleteProvider": "删除提供商",
"deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。",
"selectProvider": "选择一个提供商或添加新的",
"configureMultiple": "配置多个 AI 提供商并轻松切换",
"apiKeyStored": "API 密钥存储在您的浏览器本地",
"test": "测试",
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "添加提供商即可开始使用",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
"awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
"selectRegion": "选择区域",
"apiKey": "API 密钥",
"enterApiKey": "输入您的 API 密钥",
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"customEndpoint": "自定义端点 URL",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
"suggested": "推荐",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入提供商名称...",
"modelsConfiguredCount": "已配置 {count} 个模型",
"validationFailedCount": "{count} 个模型验证失败",
"cancel": "取消",
"delete": "删除",
"clickToChange": "(点击更改)",
"usingServerDefault": "使用服务器默认模型",
"selectModel": "选择模型",
"searchModels": "搜索模型...",
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型"
}
}

View File

@@ -24,4 +24,8 @@ export const STORAGE_KEYS = {
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
// Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs",
selectedModelId: "next-ai-draw-io-selected-model-id",
} as const

277
lib/types/model-config.ts Normal file
View File

@@ -0,0 +1,277 @@
// Types for multi-provider model configuration
export type ProviderName =
| "openai"
| "anthropic"
| "google"
| "azure"
| "bedrock"
| "openrouter"
| "deepseek"
| "siliconflow"
| "gateway"
// Individual model configuration
export interface ModelConfig {
id: string // UUID for this model
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed
}
// Provider configuration
export interface ProviderConfig {
id: string // UUID for this provider config
provider: ProviderName
name?: string // Custom display name (e.g., "OpenAI Production")
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string // Optional, for temporary credentials
models: ModelConfig[]
validated?: boolean // Has API key been validated
}
// The complete multi-model configuration
export interface MultiModelConfig {
version: 1
providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID
}
// Flattened model for dropdown display
export interface FlattenedModel {
id: string // Model config UUID
modelId: string // Actual model ID
provider: ProviderName
providerLabel: string // Provider display name
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
validated?: boolean // Has this model been validated
}
// Provider metadata
export const PROVIDER_INFO: Record<
ProviderName,
{ label: string; defaultBaseUrl?: string }
> = {
openai: { label: "OpenAI" },
anthropic: {
label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1",
},
google: { label: "Google" },
azure: { label: "Azure OpenAI" },
bedrock: { label: "Amazon Bedrock" },
openrouter: { label: "OpenRouter" },
deepseek: { label: "DeepSeek" },
siliconflow: {
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.com/v1",
},
gateway: { label: "AI Gateway" },
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [
// GPT-4o series (latest)
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-11-20",
// GPT-4 Turbo
"gpt-4-turbo",
"gpt-4-turbo-preview",
// o1/o3 reasoning models
"o1",
"o1-mini",
"o1-preview",
"o3-mini",
// GPT-4
"gpt-4",
// GPT-3.5
"gpt-3.5-turbo",
],
anthropic: [
// Claude 4.5 series (latest)
"claude-opus-4-5-20250514",
"claude-sonnet-4-5-20250514",
// Claude 4 series
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
// Claude 3.7 series
"claude-3-7-sonnet-20250219",
// Claude 3.5 series
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
// Claude 3 series
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
google: [
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-preview-05-20",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-lite",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
// Legacy
"gemini-pro",
],
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [
// Anthropic Claude
"anthropic.claude-opus-4-5-20250514-v1:0",
"anthropic.claude-sonnet-4-5-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
// Amazon Nova
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0",
// Meta Llama
"meta.llama3-3-70b-instruct-v1:0",
"meta.llama3-1-405b-instruct-v1:0",
"meta.llama3-1-70b-instruct-v1:0",
// Mistral
"mistral.mistral-large-2411-v1:0",
"mistral.mistral-small-2503-v1:0",
],
openrouter: [
// Anthropic
"anthropic/claude-sonnet-4",
"anthropic/claude-opus-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-haiku",
// OpenAI
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/o1",
"openai/o3-mini",
// Google
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash-exp:free",
// Meta Llama
"meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-405b-instruct",
"meta-llama/llama-3.1-70b-instruct",
// DeepSeek
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
// Qwen
"qwen/qwen-2.5-72b-instruct",
],
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
siliconflow: [
// DeepSeek
"deepseek-ai/DeepSeek-V3",
"deepseek-ai/DeepSeek-R1",
"deepseek-ai/DeepSeek-V2.5",
// Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen2.5-Coder-32B-Instruct",
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct",
],
gateway: [
"openai/gpt-4o",
"openai/gpt-4o-mini",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash",
],
}
// Helper to generate UUID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
// Create empty config
export function createEmptyConfig(): MultiModelConfig {
return {
version: 1,
providers: [],
selectedModelId: undefined,
}
}
// Create new provider config
export function createProviderConfig(provider: ProviderName): ProviderConfig {
return {
id: generateId(),
provider,
apiKey: "",
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
models: [],
validated: false,
}
}
// Create new model config
export function createModelConfig(modelId: string): ModelConfig {
return {
id: generateId(),
modelId,
}
}
// Get all models as flattened list for dropdown
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = []
for (const provider of config.providers) {
// Use custom name if provided, otherwise use default provider label
const providerLabel =
provider.name || PROVIDER_INFO[provider.provider].label
for (const model of provider.models) {
models.push({
id: model.id,
modelId: model.modelId,
provider: provider.provider,
providerLabel,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
// AWS Bedrock fields
awsAccessKeyId: provider.awsAccessKeyId,
awsSecretAccessKey: provider.awsSecretAccessKey,
awsRegion: provider.awsRegion,
awsSessionToken: provider.awsSessionToken,
validated: model.validated,
})
}
}
return models
}
// Find model by ID
export function findModelById(
config: MultiModelConfig,
modelId: string,
): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId)
}

View File

@@ -3,6 +3,8 @@
import { useCallback, useMemo } from "react"
import { toast } from "sonner"
import { QuotaLimitToast } from "@/components/quota-limit-toast"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
export interface QuotaConfig {
@@ -40,6 +42,8 @@ export function useQuotaManager(config: QuotaConfig): {
} {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary()
// Check if user has their own API key configured (bypass limits)
const hasOwnApiKey = useCallback((): boolean => {
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
@@ -221,11 +225,12 @@ export function useQuotaManager(config: QuotaConfig): {
const showTPMLimitToast = useCallback(() => {
const limitDisplay =
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
toast.error(
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
{ duration: 8000 },
)
}, [tpmLimit])
const message = formatMessage(dict.quota.tpmMessageDetailed, {
limit: limitDisplay,
seconds: 60,
})
toast.error(message, { duration: 8000 })
}, [tpmLimit, dict])
return {
// Check functions

View File

@@ -1,8 +1,19 @@
import type { NextConfig } from "next"
import packageJson from "./package.json"
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
env: {
APP_VERSION: packageJson.version,
},
// Include instrumentation.ts in standalone build for Langfuse telemetry
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
},
}
export default nextConfig

6457
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "next-ai-draw-io",
"version": "0.4.3",
"version": "0.4.6",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "next dev --turbopack --port 6002",
"build": "next build",
@@ -10,40 +11,53 @@
"lint": "biome lint .",
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky"
"prepare": "husky",
"electron:dev": "node scripts/electron-dev.mjs",
"electron:build": "npm run build && npm run electron:compile",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
"electron:start": "npx cross-env NODE_ENV=development npx electron .",
"electron:prepare": "node scripts/prepare-electron-build.mjs",
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107",
"@ai-sdk/amazon-bedrock": "^4.0.1",
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1",
"@aws-sdk/credential-providers": "^3.943.0",
"@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@openrouter/ai-sdk-provider": "^1.2.3",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^5.0.89",
"ai": "^6.0.1",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"js-tiktoken": "^1.0.21",
"jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
@@ -84,11 +98,23 @@
"@types/pako": "^2.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.27.2",
"eslint": "9.39.1",
"eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"wait-on": "^9.0.3"
},
"overrides": {
"@openrouter/ai-sdk-provider": {
"ai": "^6.0.1"
}
}
}

View File

@@ -86,6 +86,7 @@ Use the standard MCP configuration with:
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
- **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

View File

@@ -1,12 +1,12 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.3",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.3",
"version": "0.1.5",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.3",
"version": "0.1.5",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",

View File

@@ -0,0 +1,62 @@
/**
* Simple diagram history - matches Next.js app pattern
* Stores {xml, svg} entries in a circular buffer
*/
import { log } from "./logger.js"
const MAX_HISTORY = 20
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
if (!history) {
history = []
historyStore.set(sessionId, history)
}
// Dedupe: skip if same as last entry
const last = history[history.length - 1]
if (last?.xml === xml) {
return history.length - 1
}
history.push({ xml, svg })
// Circular buffer
if (history.length > MAX_HISTORY) {
history.shift()
}
log.debug(`History: session=${sessionId}, entries=${history.length}`)
return history.length - 1
}
export function getHistory(
sessionId: string,
): Array<{ xml: string; svg: string }> {
return historyStore.get(sessionId) || []
}
export function getHistoryEntry(
sessionId: string,
index: number,
): { xml: string; svg: string } | undefined {
const history = historyStore.get(sessionId)
return history?.[index]
}
export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
const history = historyStore.get(sessionId)
if (!history || history.length === 0) return false
const last = history[history.length - 1]
if (!last.svg) {
last.svg = svg
return true
}
return false
}

View File

@@ -1,55 +1,77 @@
/**
* Embedded HTTP Server for MCP
*
* Serves a static HTML page with draw.io embed and handles state sync.
* This eliminates the need for an external Next.js app.
* Serves draw.io embed with state sync and history UI
*/
import http from "node:http"
import {
addHistory,
clearHistory,
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.js"
import { log } from "./logger.js"
interface SessionState {
xml: string
version: number
lastUpdated: Date
svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
}
// In-memory state store (shared with MCP server in same process)
export const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null
let serverPort: number = 6002
const MAX_PORT = 6020 // Don't retry beyond this port
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
let serverPort = 6002
const MAX_PORT = 6020
const SESSION_TTL = 60 * 60 * 1000
/**
* Get state for a session
*/
export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
/**
* Set state for a session
*/
export function setState(sessionId: string, xml: string): number {
export function setState(sessionId: string, xml: string, svg?: string): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
stateStore.set(sessionId, {
xml,
version: newVersion,
lastUpdated: new Date(),
svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Start the embedded HTTP server
*/
export function startHttpServer(port: number = 6002): Promise<number> {
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
state.syncRequested = Date.now()
log.debug(`Sync requested for session=${sessionId}`)
return true
}
log.debug(`Sync requested for non-existent session=${sessionId}`)
return false
}
export async function waitForSync(
sessionId: string,
timeoutMs = 3000,
): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const state = stateStore.get(sessionId)
if (!state?.syncRequested) return true // Sync completed
await new Promise((r) => setTimeout(r, 100))
}
log.warn(`Sync timeout for session=${sessionId}`)
return false // Timeout
}
export function startHttpServer(port = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
resolve(serverPort)
@@ -81,15 +103,12 @@ export function startHttpServer(port: number = 6002): Promise<number> {
server.listen(port, () => {
serverPort = port
log.info(`Embedded HTTP server running on http://localhost:${port}`)
log.info(`HTTP server running on http://localhost:${port}`)
resolve(port)
})
})
}
/**
* Stop the HTTP server
*/
export function stopHttpServer(): void {
if (server) {
server.close()
@@ -97,39 +116,29 @@ export function stopHttpServer(): void {
}
}
/**
* Clean up expired sessions
*/
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionId, state] of stateStore) {
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
stateStore.delete(sessionId)
clearHistory(sessionId)
log.info(`Cleaned up expired session: ${sessionId}`)
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
/**
* Get the current server port
*/
export function getServerPort(): number {
return serverPort
}
/**
* Handle HTTP requests
*/
function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
// CORS headers for local development
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
@@ -140,43 +149,23 @@ function handleRequest(
return
}
// Route handling
if (url.pathname === "/" || url.pathname === "/index.html") {
serveHtml(req, res, url)
} else if (
url.pathname === "/api/state" ||
url.pathname === "/api/mcp/state"
) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (
url.pathname === "/api/health" ||
url.pathname === "/api/mcp/health"
) {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ status: "ok", mcp: true }))
} else if (url.pathname === "/api/history") {
handleHistoryApi(req, res, url)
} else if (url.pathname === "/api/restore") {
handleRestoreApi(req, res)
} else if (url.pathname === "/api/history-svg") {
handleHistorySvgApi(req, res)
} else {
res.writeHead(404)
res.end("Not Found")
}
}
/**
* Serve the HTML page with draw.io embed
*/
function serveHtml(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
const sessionId = url.searchParams.get("mcp") || ""
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
}
/**
* Handle state API requests
*/
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -189,14 +178,13 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
xml: state?.xml || null,
version: state?.version || 0,
lastUpdated: state?.lastUpdated?.toISOString() || null,
syncRequested: !!state?.syncRequested,
}),
)
} else if (req.method === "POST") {
@@ -206,14 +194,13 @@ function handleStateApi(
})
req.on("end", () => {
try {
const { sessionId, xml } = JSON.parse(body)
const { sessionId, xml, svg } = JSON.parse(body)
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const version = setState(sessionId, xml)
const version = setState(sessionId, xml, svg)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
@@ -227,35 +214,179 @@ function handleStateApi(
}
}
/**
* Generate the HTML page with draw.io embed
*/
function handleHistoryApi(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
if (req.method !== "GET") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
const sessionId = url.searchParams.get("sessionId")
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const history = getHistory(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
count: history.length,
}),
)
}
function handleRestoreApi(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
if (req.method !== "POST") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, index } = JSON.parse(body)
if (!sessionId || index === undefined) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(
JSON.stringify({ error: "sessionId and index required" }),
)
return
}
const entry = getHistoryEntry(sessionId, index)
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" }))
return
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to index ${index}`)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
}
function handleHistorySvgApi(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
if (req.method !== "POST") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, svg } = JSON.parse(body)
if (!sessionId || !svg) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId and svg required" }))
return
}
updateLastHistorySvg(sessionId, svg)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
}
function getHtmlPage(sessionId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
<title>Draw.io MCP</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 8px 16px;
background: #1a1a2e;
color: #eee;
font-family: system-ui, sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px; background: #1a1a2e; color: #eee;
font-family: system-ui, sans-serif; font-size: 14px;
display: flex; justify-content: space-between; align-items: center;
}
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
#history-btn {
position: fixed; bottom: 24px; right: 24px;
width: 48px; height: 48px; border-radius: 50%;
background: #3b82f6; color: white; border: none; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
#history-btn:hover { background: #2563eb; }
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
#history-btn svg { width: 24px; height: 24px; }
#history-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 2000;
align-items: center; justify-content: center;
}
#history-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 12px;
width: 90%; max-width: 500px; max-height: 70vh;
display: flex; flex-direction: column;
}
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
.modal-header h2 { font-size: 18px; margin: 0; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.history-item {
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
cursor: pointer; text-align: center;
}
.history-item:hover { border-color: #3b82f6; }
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
.history-item .thumb {
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px; overflow: hidden;
}
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 12px; color: #666; }
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-secondary { background: #f3f4f6; color: #374151; }
.empty { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
@@ -263,121 +394,191 @@ function getHtmlPage(sessionId: string): string {
<div id="header">
<div>
<strong>Draw.io MCP</strong>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div id="history-modal">
<div class="modal-content">
<div class="modal-header"><h2>History</h2></div>
<div class="modal-body">
<div id="history-grid" class="history-grid"></div>
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
</div>
</div>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null;
let pendingAiSvg = false;
let currentVersion = 0;
let isDrawioReady = false;
let pendingXml = null;
let lastLoadedXml = null;
// Listen for messages from draw.io
window.addEventListener('message', (event) => {
if (event.origin !== 'https://embed.diagrams.net') return;
window.addEventListener('message', (e) => {
if (e.origin !== 'https://embed.diagrams.net') return;
try {
const msg = JSON.parse(event.data);
handleDrawioMessage(msg);
} catch (e) {
// Ignore non-JSON messages
}
const msg = JSON.parse(e.data);
if (msg.event === 'init') {
isReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Request SVG export, then push state with SVG
pendingSvgExport = msg.xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) {
// Handle sync export (XML format) - server requested fresh state
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
pendingSyncExport = false;
pushState(msg.data, '');
return;
}
// Handle SVG export
let svg = msg.data;
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg);
} else if (pendingAiSvg) {
pendingAiSvg = false;
fetch('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg })
}).catch(() => {});
}
}
} catch {}
});
function handleDrawioMessage(msg) {
if (msg.event === 'init') {
isDrawioReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
// Load pending XML if any
if (pendingXml) {
loadDiagram(pendingXml);
pendingXml = null;
}
} else if (msg.event === 'save') {
// User saved - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
} else if (msg.event === 'export') {
// Export completed
if (msg.data) {
pushState(msg.data);
}
} else if (msg.event === 'autosave') {
// Autosave - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
function loadDiagram(xml, capturePreview = false) {
if (!isReady) { pendingXml = xml; return; }
lastXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
if (capturePreview) {
setTimeout(() => {
pendingAiSvg = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
}, 500);
}
}
function loadDiagram(xml) {
if (!isDrawioReady) {
pendingXml = xml;
return;
}
lastLoadedXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
xml: xml,
autosave: 1
}), '*');
}
async function pushState(xml) {
async function pushState(xml, svg = '') {
if (!sessionId) return;
try {
const response = await fetch('/api/state', {
const r = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml })
body: JSON.stringify({ sessionId, xml, svg })
});
if (response.ok) {
const result = await response.json();
currentVersion = result.version;
lastLoadedXml = xml;
}
} catch (e) {
console.error('Failed to push state:', e);
}
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
} catch (e) { console.error('Push failed:', e); }
}
async function pollState() {
let pendingSyncExport = false;
async function poll() {
if (!sessionId) return;
try {
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!response.ok) return;
const state = await response.json();
if (state.version && state.version > currentVersion && state.xml) {
currentVersion = state.version;
loadDiagram(state.xml);
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// Handle sync request - server needs fresh state
if (s.syncRequested && !pendingSyncExport) {
pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
}
} catch (e) {
console.error('Failed to poll state:', e);
}
// Load new diagram from server
if (s.version > currentVersion && s.xml) {
currentVersion = s.version;
loadDiagram(s.xml, true);
}
} catch {}
}
// Start polling if we have a session
if (sessionId) {
pollState();
setInterval(pollState, 2000);
if (sessionId) { poll(); setInterval(poll, 2000); }
// History UI
const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal');
const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedIdx = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
historyData = d.entries || [];
renderHistory();
}
} catch {}
historyModal.classList.add('open');
};
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
function renderHistory() {
if (historyData.length === 0) {
historyGrid.style.display = 'none';
historyEmpty.style.display = 'block';
return;
}
historyGrid.style.display = 'grid';
historyEmpty.style.display = 'none';
historyGrid.innerHTML = historyData.map((e, i) => \`
<div class="history-item" data-idx="\${e.index}">
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
<div class="label">#\${e.index}</div>
</div>
\`).join('');
historyGrid.querySelectorAll('.history-item').forEach(item => {
item.onclick = () => {
const idx = parseInt(item.dataset.idx);
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
else { selectedIdx = idx; restoreBtn.disabled = false; }
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
};
});
}
restoreBtn.onclick = async () => {
if (selectedIdx === null) return;
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring...';
try {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, index: selectedIdx })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else { alert('Restore failed'); }
} catch { alert('Restore failed'); }
restoreBtn.textContent = 'Restore';
};
</script>
</body>
</html>`

View File

@@ -34,11 +34,13 @@ import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import { addHistory } from "./history.js"
import {
getServerPort,
getState,
requestSync,
setState,
startHttpServer,
waitForSync,
} from "./http-server.js"
import { log } from "./logger.js"
import { validateAndFixXml } from "./xml-validation.js"
@@ -53,6 +55,7 @@ let currentSession: {
id: string
xml: string
version: number
lastGetDiagramTime: number // Track when get_diagram was last called (for enforcing workflow)
} | null = null
// Create MCP server
@@ -118,6 +121,7 @@ server.registerTool(
id: sessionId,
xml: "",
version: 0,
lastGetDiagramTime: 0,
}
// Open browser
@@ -197,6 +201,21 @@ server.registerTool(
log.info(`Displaying diagram, ${xml.length} chars`)
// Sync from browser state first
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
// Save user's state before AI overwrites (with cached SVG)
if (currentSession.xml) {
addHistory(
currentSession.id,
currentSession.xml,
browserState?.svg || "",
)
}
// Update session state
currentSession.xml = xml
currentSession.version++
@@ -204,6 +223,9 @@ server.registerTool(
// Push to embedded server state
setState(currentSession.id, xml)
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, xml, "")
log.info(`Diagram displayed successfully`)
return {
@@ -231,11 +253,14 @@ server.registerTool(
"edit_diagram",
{
description:
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
"IMPORTANT workflow:\n" +
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" +
"Edit the current diagram by ID-based operations (update/add/delete cells).\n\n" +
"⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\n" +
"This fetches the latest state from the browser including any manual user edits.\n" +
"Skipping get_diagram WILL cause user's changes to be LOST.\n\n" +
"Workflow:\n" +
"1. Call get_diagram to see current cell IDs and structure\n" +
"2. Use the returned XML to construct your edit operations\n" +
"3. Call edit_diagram with your operations\n\n" +
"Operations:\n" +
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
@@ -274,6 +299,27 @@ server.registerTool(
}
}
// Enforce workflow: require get_diagram to be called first
const timeSinceGet = Date.now() - currentSession.lastGetDiagramTime
if (timeSinceGet > 30000) {
// 30 seconds
log.warn(
"edit_diagram called without recent get_diagram - rejecting to prevent data loss",
)
return {
content: [
{
type: "text",
text:
"Error: You must call get_diagram first before edit_diagram.\n\n" +
"This ensures you have the latest diagram state including any manual edits the user made in the browser. " +
"Please call get_diagram, then use that XML to construct your edit operations.",
},
],
isError: true,
}
}
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
@@ -295,6 +341,13 @@ server.registerTool(
log.info(`Editing diagram with ${operations.length} operation(s)`)
// Save before editing (with cached SVG from browser)
addHistory(
currentSession.id,
currentSession.xml,
browserState?.svg || "",
)
// Validate and auto-fix new_xml for each operation
const validatedOps = operations.map((op) => {
if (op.new_xml) {
@@ -336,6 +389,9 @@ server.registerTool(
// Push to embedded server
setState(currentSession.id, result)
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, result, "")
log.info(`Diagram edited successfully`)
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
@@ -387,6 +443,18 @@ server.registerTool(
}
}
// Request browser to push fresh state and wait for it
const syncRequested = requestSync(currentSession.id)
if (syncRequested) {
const synced = await waitForSync(currentSession.id)
if (!synced) {
log.warn("get_diagram: sync timeout - state may be stale")
}
}
// Mark that get_diagram was called (for edit_diagram workflow check)
currentSession.lastGetDiagramTime = Date.now()
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

43
scripts/afterPack.cjs Normal file
View File

@@ -0,0 +1,43 @@
/**
* electron-builder afterPack hook
* Copies node_modules to the standalone directory in the packaged app
*/
const { cpSync, existsSync } = require("fs")
const path = require("path")
module.exports = async (context) => {
const appOutDir = context.appOutDir
const resourcesDir = path.join(
appOutDir,
context.packager.platform.name === "mac"
? `${context.packager.appInfo.productFilename}.app/Contents/Resources`
: "resources",
)
const standaloneDir = path.join(resourcesDir, "standalone")
const sourceNodeModules = path.join(
context.packager.projectDir,
"electron-standalone",
"node_modules",
)
const targetNodeModules = path.join(standaloneDir, "node_modules")
console.log(`[afterPack] Copying node_modules to ${targetNodeModules}`)
if (existsSync(sourceNodeModules) && existsSync(standaloneDir)) {
cpSync(sourceNodeModules, targetNodeModules, { recursive: true })
console.log("[afterPack] node_modules copied successfully")
} else {
console.error("[afterPack] Source or target directory not found!")
console.error(
` Source: ${sourceNodeModules} exists: ${existsSync(sourceNodeModules)}`,
)
console.error(
` Target dir: ${standaloneDir} exists: ${existsSync(standaloneDir)}`,
)
throw new Error(
"[afterPack] Failed: Required directories not found. " +
"Ensure 'npm run electron:prepare' was run before building.",
)
}
}

295
scripts/electron-dev.mjs Normal file
View File

@@ -0,0 +1,295 @@
#!/usr/bin/env node
/**
* Development script for running Electron with Next.js
* 1. Reads preset configuration (if exists)
* 2. Starts Next.js dev server with preset env vars
* 3. Waits for it to be ready
* 4. Compiles Electron TypeScript
* 5. Launches Electron
* 6. Watches for preset changes and restarts Next.js
*/
import { spawn } from "node:child_process"
import { existsSync, readFileSync, watch } from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.join(__dirname, "..")
const NEXT_PORT = 6002
const NEXT_URL = `http://localhost:${NEXT_PORT}`
/**
* Get the user data path (same as Electron's app.getPath("userData"))
*/
function getUserDataPath() {
const appName = "next-ai-draw-io"
switch (process.platform) {
case "darwin":
return path.join(
os.homedir(),
"Library",
"Application Support",
appName,
)
case "win32":
return path.join(
process.env.APPDATA ||
path.join(os.homedir(), "AppData", "Roaming"),
appName,
)
default:
return path.join(os.homedir(), ".config", appName)
}
}
/**
* Load preset configuration from config file
*/
function loadPresetConfig() {
const configPath = path.join(getUserDataPath(), "config-presets.json")
if (!existsSync(configPath)) {
console.log("📋 No preset configuration found, using .env.local")
return null
}
try {
const content = readFileSync(configPath, "utf-8")
const data = JSON.parse(content)
if (!data.currentPresetId) {
console.log("📋 No active preset, using .env.local")
return null
}
const preset = data.presets.find((p) => p.id === data.currentPresetId)
if (!preset) {
console.log("📋 Active preset not found, using .env.local")
return null
}
console.log(`📋 Using preset: "${preset.name}"`)
return preset.config
} catch (error) {
console.error("Failed to load preset config:", error.message)
return null
}
}
/**
* Wait for the Next.js server to be ready
*/
async function waitForServer(url, timeout = 120000) {
const start = Date.now()
console.log(`Waiting for server at ${url}...`)
while (Date.now() - start < timeout) {
try {
const response = await fetch(url)
if (response.ok || response.status < 500) {
console.log("Server is ready!")
return true
}
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, 500))
process.stdout.write(".")
}
throw new Error(`Timeout waiting for server at ${url}`)
}
/**
* Run a command and wait for it to complete
*/
function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, {
cwd: rootDir,
stdio: "inherit",
shell: true,
...options,
})
proc.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`Command failed with code ${code}`))
}
})
proc.on("error", reject)
})
}
/**
* Start Next.js dev server with preset environment
*/
function startNextServer(presetEnv) {
const env = { ...process.env }
// Apply preset environment variables
if (presetEnv) {
for (const [key, value] of Object.entries(presetEnv)) {
if (value !== undefined && value !== "") {
env[key] = value
}
}
}
const nextProcess = spawn("npm", ["run", "dev"], {
cwd: rootDir,
stdio: "inherit",
shell: true,
env,
})
nextProcess.on("error", (err) => {
console.error("Failed to start Next.js:", err)
})
return nextProcess
}
/**
* Main entry point
*/
async function main() {
console.log("🚀 Starting Electron development environment...\n")
// Load preset configuration
const presetEnv = loadPresetConfig()
// Start Next.js dev server with preset env
console.log("1. Starting Next.js development server...")
let nextProcess = startNextServer(presetEnv)
// Wait for Next.js to be ready
try {
await waitForServer(NEXT_URL)
console.log("")
} catch (err) {
console.error("\n❌ Next.js server failed to start:", err.message)
nextProcess.kill()
process.exit(1)
}
// Compile Electron TypeScript
console.log("\n2. Compiling Electron code...")
try {
await runCommand("npm", ["run", "electron:compile"])
} catch (err) {
console.error("❌ Electron compilation failed:", err.message)
nextProcess.kill()
process.exit(1)
}
// Start Electron
console.log("\n3. Starting Electron...")
const electronProcess = spawn("npm", ["run", "electron:start"], {
cwd: rootDir,
stdio: "inherit",
shell: true,
env: {
...process.env,
NODE_ENV: "development",
ELECTRON_DEV_URL: NEXT_URL,
},
})
// Watch for preset config changes
const configPath = path.join(getUserDataPath(), "config-presets.json")
let configWatcher = null
let restartPending = false
function setupConfigWatcher() {
if (!existsSync(path.dirname(configPath))) {
// Directory doesn't exist yet, check again later
setTimeout(setupConfigWatcher, 5000)
return
}
try {
configWatcher = watch(
configPath,
{ persistent: false },
async (eventType) => {
if (eventType === "change" && !restartPending) {
restartPending = true
console.log(
"\n🔄 Preset configuration changed, restarting Next.js server...",
)
// Kill current Next.js process
nextProcess.kill()
// Wait a bit for process to die
await new Promise((r) => setTimeout(r, 1000))
// Reload preset and restart
const newPresetEnv = loadPresetConfig()
nextProcess = startNextServer(newPresetEnv)
try {
await waitForServer(NEXT_URL)
console.log(
"✅ Next.js server restarted with new configuration\n",
)
} catch (err) {
console.error(
"❌ Failed to restart Next.js:",
err.message,
)
}
restartPending = false
}
},
)
console.log("👀 Watching for preset configuration changes...")
} catch (err) {
// File might not exist yet, that's ok
setTimeout(setupConfigWatcher, 5000)
}
}
// Start watching after a delay (config file might not exist yet)
setTimeout(setupConfigWatcher, 2000)
electronProcess.on("close", (code) => {
console.log(`\nElectron exited with code ${code}`)
if (configWatcher) configWatcher.close()
nextProcess.kill()
process.exit(code || 0)
})
electronProcess.on("error", (err) => {
console.error("Electron error:", err)
if (configWatcher) configWatcher.close()
nextProcess.kill()
process.exit(1)
})
// Handle termination signals
const cleanup = () => {
console.log("\n🛑 Shutting down...")
if (configWatcher) configWatcher.close()
electronProcess.kill()
nextProcess.kill()
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
}
main().catch((err) => {
console.error("Fatal error:", err)
process.exit(1)
})

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Prepare standalone directory for Electron packaging
* Copies the Next.js standalone output to a temp directory
* that electron-builder can properly include
*/
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const rootDir = join(__dirname, "..")
const standaloneDir = join(rootDir, ".next", "standalone")
const staticDir = join(rootDir, ".next", "static")
const targetDir = join(rootDir, "electron-standalone")
console.log("Preparing Electron build...")
// Clean target directory
if (existsSync(targetDir)) {
console.log("Cleaning previous build...")
rmSync(targetDir, { recursive: true })
}
// Create target directory
mkdirSync(targetDir, { recursive: true })
// Copy standalone (includes node_modules)
console.log("Copying standalone directory...")
cpSync(standaloneDir, targetDir, { recursive: true })
// Copy static files
console.log("Copying static files...")
const targetStaticDir = join(targetDir, ".next", "static")
mkdirSync(targetStaticDir, { recursive: true })
cpSync(staticDir, targetStaticDir, { recursive: true })
console.log("Done! Files prepared in electron-standalone/")

View File

@@ -29,5 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", "packages"]
"exclude": ["node_modules", "packages", "electron", "dist-electron"]
}