Compare commits
27 Commits
v0.4.4
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6086c4177a | ||
|
|
33fd2a16e6 | ||
|
|
41c450516c | ||
|
|
0e8783ccfb | ||
|
|
7cf6d7e7bd | ||
|
|
7ed7b29274 | ||
|
|
1be0cfa06c | ||
|
|
1f6ef7ac90 | ||
|
|
56ca9d3f48 | ||
|
|
e089702949 | ||
|
|
89b0a96b95 | ||
|
|
1e916aa86e | ||
|
|
b088a0653e | ||
|
|
b25b944600 | ||
|
|
4f07a5fafc | ||
|
|
fc5eca877a | ||
|
|
f58274bb84 | ||
|
|
e03b65328d | ||
|
|
14c1aa8e1c | ||
|
|
9e651a51e6 | ||
|
|
2871265362 | ||
|
|
9d13bd7451 | ||
|
|
b97f3ccda9 | ||
|
|
864375b8e4 | ||
|
|
b9bc2a72c6 | ||
|
|
c215d80688 | ||
|
|
74b9e38114 |
46
.github/workflows/electron-release.yml
vendored
Normal 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
@@ -50,3 +50,16 @@ push-via-ec2.sh
|
|||||||
.wrangler/
|
.wrangler/
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
/dist-electron/
|
||||||
|
/release/
|
||||||
|
/electron-standalone/
|
||||||
|
*.dmg
|
||||||
|
*.exe
|
||||||
|
*.AppImage
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
*.snap
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
.spec-workflow
|
||||||
23
README.md
@@ -33,6 +33,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [MCP Server (Preview)](#mcp-server-preview)
|
- [MCP Server (Preview)](#mcp-server-preview)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Try it Online](#try-it-online)
|
- [Try it Online](#try-it-online)
|
||||||
|
- [Desktop Application](#desktop-application)
|
||||||
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [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.
|
> **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)
|
### Run with Docker (Recommended)
|
||||||
|
|
||||||
If you just want to run it locally, the best way is to use Docker.
|
If you just want to run it locally, the best way is to use Docker.
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
baseUrl: req.headers.get("x-ai-base-url"),
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
|
// 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
|
// Read minimal style preference from header
|
||||||
|
|||||||
213
app/api/validate-model/route.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
components/ai-elements/model-selector.tsx
Normal 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} />
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ import { toast } from "sonner"
|
|||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -28,6 +29,7 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -156,6 +158,11 @@ interface ChatInputProps {
|
|||||||
error?: Error | null
|
error?: Error | null
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
|
// Model selector props
|
||||||
|
models?: FlattenedModel[]
|
||||||
|
selectedModelId?: string
|
||||||
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
|
onConfigureModels?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -173,6 +180,10 @@ export function ChatInput({
|
|||||||
error = null,
|
error = null,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
|
models = [],
|
||||||
|
selectedModelId,
|
||||||
|
onModelSelect = () => {},
|
||||||
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
@@ -465,6 +476,14 @@ export function ChatInput({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
onConfigure={onConfigureModels}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import { FaGithub } from "react-icons/fa"
|
|||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getAIConfig } from "@/lib/ai-config"
|
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
@@ -146,7 +147,10 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSettingsDialog, setShowSettingsDialog] = 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 [input, setInput] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
@@ -167,12 +171,11 @@ export default function ChatPanel({
|
|||||||
fetch("/api/config")
|
fetch("/api/config")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setAccessCodeRequired(data.accessCodeRequired)
|
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
setTpmLimit(data.tpmLimit || 0)
|
||||||
})
|
})
|
||||||
.catch(() => setAccessCodeRequired(false))
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Quota management using extracted hook
|
// Quota management using extracted hook
|
||||||
@@ -609,8 +612,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
// Show settings button and open dialog to help user fix it
|
// Show settings dialog to help user fix it
|
||||||
setAccessCodeRequired(true)
|
|
||||||
setShowSettingsDialog(true)
|
setShowSettingsDialog(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1019,7 +1021,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
partialXmlRef.current = ""
|
partialXmlRef.current = ""
|
||||||
|
|
||||||
const config = getAIConfig()
|
const config = getSelectedAIConfig()
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
@@ -1036,6 +1038,20 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
"x-ai-api-key": config.aiApiKey,
|
"x-ai-api-key": config.aiApiKey,
|
||||||
}),
|
}),
|
||||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
...(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 && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
@@ -1361,6 +1377,10 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
error={error}
|
error={error}
|
||||||
minimalStyle={minimalStyle}
|
minimalStyle={minimalStyle}
|
||||||
onMinimalStyleChange={setMinimalStyle}
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
|
models={modelConfig.models}
|
||||||
|
selectedModelId={modelConfig.selectedModelId}
|
||||||
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1374,6 +1394,12 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
onToggleDarkMode={onToggleDarkMode}
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelConfigDialog
|
||||||
|
open={showModelConfigDialog}
|
||||||
|
onOpenChange={setShowModelConfigDialog}
|
||||||
|
modelConfig={modelConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResetWarningModal
|
<ResetWarningModal
|
||||||
open={showNewChatDialog}
|
open={showNewChatDialog}
|
||||||
onOpenChange={setShowNewChatDialog}
|
onOpenChange={setShowNewChatDialog}
|
||||||
|
|||||||
1492
components/model-config-dialog.tsx
Normal file
216
components/model-selector.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"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 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 [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} (click to change)`
|
||||||
|
: "Using server default model (click to change)"
|
||||||
|
|
||||||
|
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 : "Default"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
</ModelSelectorTrigger>
|
||||||
|
<ModelSelectorContent title="Select Model">
|
||||||
|
<ModelSelectorInput placeholder="Search models..." />
|
||||||
|
<ModelSelectorList>
|
||||||
|
<ModelSelectorEmpty>
|
||||||
|
{validatedModels.length === 0 && models.length > 0
|
||||||
|
? "No verified models. Test your models first."
|
||||||
|
: "No models found."}
|
||||||
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
|
{/* Server Default Option */}
|
||||||
|
<ModelSelectorGroup heading="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>
|
||||||
|
Server Default
|
||||||
|
</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>
|
||||||
|
Configure Models...
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
{/* Info text */}
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
|
Only verified models are shown
|
||||||
|
</div>
|
||||||
|
</ModelSelectorList>
|
||||||
|
</ModelSelectorContent>
|
||||||
|
</ModelSelectorRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,13 +12,6 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
@@ -35,10 +28,6 @@ interface SettingsDialogProps {
|
|||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
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 {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
@@ -64,10 +53,6 @@ export function SettingsDialog({
|
|||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [provider, setProvider] = useState("")
|
|
||||||
const [baseUrl, setBaseUrl] = useState("")
|
|
||||||
const [apiKey, setApiKey] = useState("")
|
|
||||||
const [modelId, setModelId] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
@@ -104,12 +89,6 @@ export function SettingsDialog({
|
|||||||
// Default to true if not set
|
// Default to true if not set
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
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("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
@@ -197,190 +176,6 @@ export function SettingsDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{dict.settings.aiProvider}</Label>
|
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
|
||||||
{dict.settings.aiProviderDescription}
|
|
||||||
</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="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="theme-toggle">
|
<Label htmlFor="theme-toggle">
|
||||||
|
|||||||
157
components/ui/alert-dialog.tsx
Normal 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
@@ -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
@@ -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 }
|
||||||
96
electron-builder.yml
Normal 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
@@ -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
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
460
electron/main/config-manager.ts
Normal 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
|
||||||
|
}
|
||||||
67
electron/main/env-loader.ts
Normal 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
@@ -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" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
212
electron/main/ipc-handlers.ts
Normal 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
161
electron/main/next-server.ts
Normal 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()
|
||||||
|
}
|
||||||
129
electron/main/port-manager.ts
Normal 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}`
|
||||||
|
}
|
||||||
78
electron/main/settings-window.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
84
electron/main/window-manager.ts
Normal 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
@@ -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),
|
||||||
|
})
|
||||||
35
electron/preload/settings.ts
Normal 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"),
|
||||||
|
})
|
||||||
110
electron/settings/index.html
Normal 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>
|
||||||
311
electron/settings/settings.css
Normal 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;
|
||||||
|
}
|
||||||
311
electron/settings/settings.js
Normal 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
@@ -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"]
|
||||||
|
}
|
||||||
373
hooks/use-model-config.ts
Normal 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 || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ export interface ClientOverrides {
|
|||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
modelId?: 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
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -41,6 +46,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"azure",
|
"azure",
|
||||||
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
@@ -537,12 +543,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case "bedrock": {
|
||||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
const hasClientCredentials =
|
||||||
const bedrockProvider = createAmazonBedrock({
|
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||||
region: process.env.AWS_REGION || "us-west-2",
|
const bedrockRegion =
|
||||||
credentialProvider: fromNodeProviderChain(),
|
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)
|
model = bedrockProvider(modelId)
|
||||||
// Add Anthropic beta options if using Claude models via Bedrock
|
// Add Anthropic beta options if using Claude models via Bedrock
|
||||||
if (modelId.includes("anthropic.claude")) {
|
if (modelId.includes("anthropic.claude")) {
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "Call for Sponsorship",
|
"seekingSponsorship": "Call for Sponsorship",
|
||||||
"contactMe": "Contact Me",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "スポンサー募集",
|
"seekingSponsorship": "スポンサー募集",
|
||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"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つのモデルを追加してください"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,5 +180,24 @@
|
|||||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"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": "请先添加至少一个模型以进行验证"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export const STORAGE_KEYS = {
|
|||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
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
|
} as const
|
||||||
|
|||||||
277
lib/types/model-config.ts
Normal 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)
|
||||||
|
}
|
||||||
6110
package-lock.json
generated
31
package.json
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "dist-electron/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack --port 6002",
|
"dev": "next dev --turbopack --port 6002",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -10,7 +11,17 @@
|
|||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"check": "biome ci",
|
"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": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
||||||
@@ -30,12 +41,14 @@
|
|||||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@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-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@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-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
@@ -44,6 +57,7 @@
|
|||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
@@ -84,11 +98,18 @@
|
|||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^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": "9.39.1",
|
||||||
"eslint-config-next": "16.0.5",
|
"eslint-config-next": "16.0.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"wait-on": "^9.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ Use the standard MCP configuration with:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
- **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.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
- **Export**: Save diagrams as `.drawio` files
|
||||||
|
|||||||
4
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.3",
|
"version": "0.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.3",
|
"version": "0.1.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"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",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
62
packages/mcp-server/src/history.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,55 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* Embedded HTTP Server for MCP
|
* Embedded HTTP Server for MCP
|
||||||
*
|
* Serves draw.io embed with state sync and history UI
|
||||||
* Serves a static HTML page with draw.io embed and handles state sync.
|
|
||||||
* This eliminates the need for an external Next.js app.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
|
import {
|
||||||
|
addHistory,
|
||||||
|
clearHistory,
|
||||||
|
getHistory,
|
||||||
|
getHistoryEntry,
|
||||||
|
updateLastHistorySvg,
|
||||||
|
} from "./history.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
xml: string
|
xml: string
|
||||||
version: number
|
version: number
|
||||||
lastUpdated: Date
|
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>()
|
export const stateStore = new Map<string, SessionState>()
|
||||||
|
|
||||||
let server: http.Server | null = null
|
let server: http.Server | null = null
|
||||||
let serverPort: number = 6002
|
let serverPort = 6002
|
||||||
const MAX_PORT = 6020 // Don't retry beyond this port
|
const MAX_PORT = 6020
|
||||||
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
|
const SESSION_TTL = 60 * 60 * 1000
|
||||||
|
|
||||||
/**
|
|
||||||
* Get state for a session
|
|
||||||
*/
|
|
||||||
export function getState(sessionId: string): SessionState | undefined {
|
export function getState(sessionId: string): SessionState | undefined {
|
||||||
return stateStore.get(sessionId)
|
return stateStore.get(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||||
* Set state for a session
|
|
||||||
*/
|
|
||||||
export function setState(sessionId: string, xml: string): number {
|
|
||||||
const existing = stateStore.get(sessionId)
|
const existing = stateStore.get(sessionId)
|
||||||
const newVersion = (existing?.version || 0) + 1
|
const newVersion = (existing?.version || 0) + 1
|
||||||
|
|
||||||
stateStore.set(sessionId, {
|
stateStore.set(sessionId, {
|
||||||
xml,
|
xml,
|
||||||
version: newVersion,
|
version: newVersion,
|
||||||
lastUpdated: new Date(),
|
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}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function requestSync(sessionId: string): boolean {
|
||||||
* Start the embedded HTTP server
|
const state = stateStore.get(sessionId)
|
||||||
*/
|
if (state) {
|
||||||
export function startHttpServer(port: number = 6002): Promise<number> {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (server) {
|
if (server) {
|
||||||
resolve(serverPort)
|
resolve(serverPort)
|
||||||
@@ -81,15 +103,12 @@ export function startHttpServer(port: number = 6002): Promise<number> {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
serverPort = port
|
serverPort = port
|
||||||
log.info(`Embedded HTTP server running on http://localhost:${port}`)
|
log.info(`HTTP server running on http://localhost:${port}`)
|
||||||
resolve(port)
|
resolve(port)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the HTTP server
|
|
||||||
*/
|
|
||||||
export function stopHttpServer(): void {
|
export function stopHttpServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
@@ -97,39 +116,29 @@ export function stopHttpServer(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired sessions
|
|
||||||
*/
|
|
||||||
function cleanupExpiredSessions(): void {
|
function cleanupExpiredSessions(): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [sessionId, state] of stateStore) {
|
for (const [sessionId, state] of stateStore) {
|
||||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||||
stateStore.delete(sessionId)
|
stateStore.delete(sessionId)
|
||||||
|
clearHistory(sessionId)
|
||||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup every 5 minutes
|
|
||||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current server port
|
|
||||||
*/
|
|
||||||
export function getServerPort(): number {
|
export function getServerPort(): number {
|
||||||
return serverPort
|
return serverPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle HTTP requests
|
|
||||||
*/
|
|
||||||
function handleRequest(
|
function handleRequest(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
): void {
|
): void {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
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-Origin", "*")
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
@@ -140,43 +149,23 @@ function handleRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route handling
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
serveHtml(req, res, url)
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
} else if (
|
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
|
||||||
url.pathname === "/api/state" ||
|
} else if (url.pathname === "/api/state") {
|
||||||
url.pathname === "/api/mcp/state"
|
|
||||||
) {
|
|
||||||
handleStateApi(req, res, url)
|
handleStateApi(req, res, url)
|
||||||
} else if (
|
} else if (url.pathname === "/api/history") {
|
||||||
url.pathname === "/api/health" ||
|
handleHistoryApi(req, res, url)
|
||||||
url.pathname === "/api/mcp/health"
|
} else if (url.pathname === "/api/restore") {
|
||||||
) {
|
handleRestoreApi(req, res)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
} else if (url.pathname === "/api/history-svg") {
|
||||||
res.end(JSON.stringify({ status: "ok", mcp: true }))
|
handleHistorySvgApi(req, res)
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404)
|
res.writeHead(404)
|
||||||
res.end("Not Found")
|
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(
|
function handleStateApi(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -189,14 +178,13 @@ function handleStateApi(
|
|||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
xml: state?.xml || null,
|
xml: state?.xml || null,
|
||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
lastUpdated: state?.lastUpdated?.toISOString() || null,
|
syncRequested: !!state?.syncRequested,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -206,14 +194,13 @@ function handleStateApi(
|
|||||||
})
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, xml } = JSON.parse(body)
|
const { sessionId, xml, svg } = JSON.parse(body)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const version = setState(sessionId, xml, svg)
|
||||||
const version = setState(sessionId, xml)
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(JSON.stringify({ success: true, version }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -227,35 +214,179 @@ function handleStateApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function handleHistoryApi(
|
||||||
* Generate the HTML page with draw.io embed
|
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 {
|
function getHtmlPage(sessionId: string): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
|
<title>Draw.io MCP</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
#header {
|
#header {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
||||||
background: #1a1a2e;
|
font-family: system-ui, sans-serif; font-size: 14px;
|
||||||
color: #eee;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
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 .session { color: #888; font-size: 12px; }
|
||||||
#header .status { font-size: 12px; }
|
#header .status { font-size: 12px; }
|
||||||
#header .status.connected { color: #4ade80; }
|
#header .status.connected { color: #4ade80; }
|
||||||
#header .status.disconnected { color: #f87171; }
|
#header .status.disconnected { color: #f87171; }
|
||||||
#drawio { flex: 1; border: none; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -263,121 +394,191 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
<div id="header">
|
<div id="header">
|
||||||
<div>
|
<div>
|
||||||
<strong>Draw.io MCP</strong>
|
<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>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
<div id="status" class="status disconnected">Connecting...</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="history-modal">
|
||||||
|
<div 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>
|
<script>
|
||||||
const sessionId = "${sessionId}";
|
const sessionId = "${sessionId}";
|
||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
|
let pendingSvgExport = null;
|
||||||
|
let pendingAiSvg = false;
|
||||||
|
|
||||||
let currentVersion = 0;
|
window.addEventListener('message', (e) => {
|
||||||
let isDrawioReady = false;
|
if (e.origin !== 'https://embed.diagrams.net') return;
|
||||||
let pendingXml = null;
|
|
||||||
let lastLoadedXml = null;
|
|
||||||
|
|
||||||
// Listen for messages from draw.io
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
if (event.origin !== 'https://embed.diagrams.net') return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(e.data);
|
||||||
handleDrawioMessage(msg);
|
if (msg.event === 'init') {
|
||||||
} catch (e) {
|
isReady = true;
|
||||||
// Ignore non-JSON messages
|
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) {
|
function loadDiagram(xml, capturePreview = false) {
|
||||||
if (msg.event === 'init') {
|
if (!isReady) { pendingXml = xml; return; }
|
||||||
isDrawioReady = true;
|
lastXml = xml;
|
||||||
statusEl.textContent = 'Ready';
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||||
statusEl.className = 'status connected';
|
if (capturePreview) {
|
||||||
|
setTimeout(() => {
|
||||||
// Load pending XML if any
|
pendingAiSvg = true;
|
||||||
if (pendingXml) {
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
loadDiagram(pendingXml);
|
}, 500);
|
||||||
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) {
|
async function pushState(xml, svg = '') {
|
||||||
if (!isDrawioReady) {
|
|
||||||
pendingXml = xml;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'load',
|
|
||||||
xml: xml,
|
|
||||||
autosave: 1
|
|
||||||
}), '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushState(xml) {
|
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state', {
|
const r = await fetch('/api/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, xml })
|
body: JSON.stringify({ sessionId, xml, svg })
|
||||||
});
|
});
|
||||||
|
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||||
if (response.ok) {
|
} catch (e) { console.error('Push failed:', e); }
|
||||||
const result = await response.json();
|
|
||||||
currentVersion = result.version;
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to push state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollState() {
|
let pendingSyncExport = false;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (!response.ok) return;
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
const state = await response.json();
|
// Handle sync request - server needs fresh state
|
||||||
|
if (s.syncRequested && !pendingSyncExport) {
|
||||||
if (state.version && state.version > currentVersion && state.xml) {
|
pendingSyncExport = true;
|
||||||
currentVersion = state.version;
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||||
loadDiagram(state.xml);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
// Load new diagram from server
|
||||||
console.error('Failed to poll state:', e);
|
if (s.version > currentVersion && s.xml) {
|
||||||
}
|
currentVersion = s.version;
|
||||||
|
loadDiagram(s.xml, true);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling if we have a session
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||||
if (sessionId) {
|
|
||||||
pollState();
|
// History UI
|
||||||
setInterval(pollState, 2000);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
type DiagramOperation,
|
type DiagramOperation,
|
||||||
} from "./diagram-operations.js"
|
} from "./diagram-operations.js"
|
||||||
|
import { addHistory } from "./history.js"
|
||||||
import {
|
import {
|
||||||
getServerPort,
|
|
||||||
getState,
|
getState,
|
||||||
|
requestSync,
|
||||||
setState,
|
setState,
|
||||||
startHttpServer,
|
startHttpServer,
|
||||||
|
waitForSync,
|
||||||
} from "./http-server.js"
|
} from "./http-server.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
import { validateAndFixXml } from "./xml-validation.js"
|
import { validateAndFixXml } from "./xml-validation.js"
|
||||||
@@ -53,6 +55,7 @@ let currentSession: {
|
|||||||
id: string
|
id: string
|
||||||
xml: string
|
xml: string
|
||||||
version: number
|
version: number
|
||||||
|
lastGetDiagramTime: number // Track when get_diagram was last called (for enforcing workflow)
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
// Create MCP server
|
// Create MCP server
|
||||||
@@ -118,6 +121,7 @@ server.registerTool(
|
|||||||
id: sessionId,
|
id: sessionId,
|
||||||
xml: "",
|
xml: "",
|
||||||
version: 0,
|
version: 0,
|
||||||
|
lastGetDiagramTime: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open browser
|
// Open browser
|
||||||
@@ -197,6 +201,21 @@ server.registerTool(
|
|||||||
|
|
||||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
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
|
// Update session state
|
||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
currentSession.version++
|
||||||
@@ -204,6 +223,9 @@ server.registerTool(
|
|||||||
// Push to embedded server state
|
// Push to embedded server state
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, xml, "")
|
||||||
|
|
||||||
log.info(`Diagram displayed successfully`)
|
log.info(`Diagram displayed successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -231,11 +253,14 @@ server.registerTool(
|
|||||||
"edit_diagram",
|
"edit_diagram",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
|
"Edit the current diagram by ID-based operations (update/add/delete cells).\n\n" +
|
||||||
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
|
"⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\n" +
|
||||||
"IMPORTANT workflow:\n" +
|
"This fetches the latest state from the browser including any manual user edits.\n" +
|
||||||
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
|
"Skipping get_diagram WILL cause user's changes to be LOST.\n\n" +
|
||||||
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\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" +
|
"Operations:\n" +
|
||||||
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\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" +
|
"- 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
|
// Fetch latest state from browser
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = getState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
@@ -295,6 +341,13 @@ server.registerTool(
|
|||||||
|
|
||||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
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
|
// Validate and auto-fix new_xml for each operation
|
||||||
const validatedOps = operations.map((op) => {
|
const validatedOps = operations.map((op) => {
|
||||||
if (op.new_xml) {
|
if (op.new_xml) {
|
||||||
@@ -336,6 +389,9 @@ server.registerTool(
|
|||||||
// Push to embedded server
|
// Push to embedded server
|
||||||
setState(currentSession.id, result)
|
setState(currentSession.id, result)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, result, "")
|
||||||
|
|
||||||
log.info(`Diagram edited successfully`)
|
log.info(`Diagram edited successfully`)
|
||||||
|
|
||||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
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
|
// Fetch latest state from browser
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = getState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
16
resources/entitlements.mac.plist
Normal 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
|
After Width: | Height: | Size: 111 KiB |
43
scripts/afterPack.cjs
Normal 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
@@ -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)
|
||||||
|
})
|
||||||
41
scripts/prepare-electron-build.mjs
Normal 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/")
|
||||||
@@ -29,5 +29,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "packages"]
|
"exclude": ["node_modules", "packages", "electron", "dist-electron"]
|
||||||
}
|
}
|
||||||
|
|||||||