mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
31 Commits
remove-ele
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c062584ed4 | ||
|
|
0fa854747d | ||
|
|
9ac99a4690 | ||
|
|
6d84dade56 | ||
|
|
43f3fbb5ee | ||
|
|
1915c817c3 | ||
|
|
eeab1ba75d | ||
|
|
1f4eb02b0b | ||
|
|
5d60ca74f7 | ||
|
|
9fa1dd075b | ||
|
|
743b317387 | ||
|
|
5ed23784e7 | ||
|
|
3a22e11651 | ||
|
|
eb89b9c052 | ||
|
|
9c1117e8b0 | ||
|
|
39bf3d6a49 | ||
|
|
ecd689162f | ||
|
|
7a03aec9be | ||
|
|
95541dd284 | ||
|
|
49af6676b5 | ||
|
|
18ab1bffa0 | ||
|
|
571ba3c6b0 | ||
|
|
467561df47 | ||
|
|
e67ab37383 | ||
|
|
31644dbcd8 | ||
|
|
067d309927 | ||
|
|
d1d0de3dea | ||
|
|
8c736cee0d | ||
|
|
c5a04c9e50 | ||
|
|
44c453403f | ||
|
|
9727aa5b39 |
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: Enhancement
|
||||||
|
about: Suggest an improvement to existing functionality
|
||||||
|
title: '[Enhancement] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
Describe how the feature currently works.
|
||||||
|
|
||||||
|
## Proposed Enhancement
|
||||||
|
How you'd like this to be improved.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Why this enhancement would be beneficial.
|
||||||
|
|
||||||
|
## Screenshots / Mockups
|
||||||
|
If applicable, add screenshots or mockups to illustrate the proposed changes.
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
Any other information about the enhancement request.
|
||||||
20
.github/workflows/auto-format.yml
vendored
20
.github/workflows/auto-format.yml
vendored
@@ -12,13 +12,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
@@ -37,11 +37,21 @@ jobs:
|
|||||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# For fork PRs, just fail if formatting is needed (can't push to forks)
|
||||||
|
- name: Fail if fork PR needs formatting
|
||||||
|
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||||
|
run: |
|
||||||
|
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
|
||||||
|
git diff --stat
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# For same-repo PRs, commit and push the changes
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
if: steps.changes.outputs.has_changes == 'true'
|
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "github-actions[bot]"
|
git config --global user.name "github-actions[bot]"
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||||
git add .
|
git add .
|
||||||
git commit -m "style: auto-format with Biome"
|
git commit -m "style: auto-format with Biome"
|
||||||
git push
|
git push origin HEAD:${{ github.head_ref }}
|
||||||
|
|||||||
44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Lint check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: npm audit --audit-level=high --omit=dev
|
||||||
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
# Push to AWS ECR for App Runner auto-deploy
|
# Push to AWS ECR for App Runner auto-deploy
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
with:
|
with:
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
|||||||
4
.github/workflows/electron-release.yml
vendored
4
.github/workflows/electron-release.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -147,13 +147,14 @@ Download the native desktop app for your platform from the [Releases page](https
|
|||||||
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
||||||
|
|
||||||
**Features:**
|
**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
|
- **Native file dialogs**: Open/save `.drawio` files directly
|
||||||
- **Offline capable**: Works without internet after first launch
|
- **Offline capable**: Works without internet after first launch
|
||||||
- **Built-in settings**: Configure AI providers directly in the app
|
|
||||||
|
|
||||||
**Quick Setup:**
|
**Quick Setup:**
|
||||||
1. Download and install for your platform
|
1. Download and install for your platform
|
||||||
2. Click the settings icon in the chat panel
|
2. Open the app → **Menu → Configuration → Manage Presets**
|
||||||
3. Add your AI provider credentials
|
3. Add your AI provider credentials
|
||||||
4. Start creating diagrams!
|
4. Start creating diagrams!
|
||||||
|
|
||||||
@@ -241,6 +242,11 @@ Or you can deploy by this button.
|
|||||||
|
|
||||||
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||||
|
|
||||||
|
## Deploy on Cloudflare Workers
|
||||||
|
|
||||||
|
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
|
||||||
const drawioBaseUrl =
|
const drawioBaseUrl =
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
@@ -24,6 +26,8 @@ export default function Home() {
|
|||||||
showSaveDialog,
|
showSaveDialog,
|
||||||
setShowSaveDialog,
|
setShowSaveDialog,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
@@ -58,6 +62,18 @@ export default function Home() {
|
|||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Restore saved locale and redirect if needed
|
||||||
|
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
||||||
|
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
||||||
|
const pathParts = pathname.split("/").filter(Boolean)
|
||||||
|
const currentLocale = pathParts[0]
|
||||||
|
if (currentLocale !== savedLocale) {
|
||||||
|
pathParts[0] = savedLocale
|
||||||
|
router.replace(`/${pathParts.join("/")}`)
|
||||||
|
return // Wait for redirect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const savedUi = localStorage.getItem("drawio-theme")
|
const savedUi = localStorage.getItem("drawio-theme")
|
||||||
if (savedUi === "min" || savedUi === "sketch") {
|
if (savedUi === "min" || savedUi === "sketch") {
|
||||||
setDrawioUi(savedUi)
|
setDrawioUi(savedUi)
|
||||||
@@ -84,7 +100,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [pathname, router])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDarkModeChange = async () => {
|
||||||
await saveDiagramToStorage()
|
await saveDiagramToStorage()
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ interface ChatInputProps {
|
|||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
|
showUnvalidatedModels?: boolean
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ export function ChatInput({
|
|||||||
models = [],
|
models = [],
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onModelSelect = () => {},
|
onModelSelect = () => {},
|
||||||
|
showUnvalidatedModels = false,
|
||||||
onConfigureModels = () => {},
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
@@ -482,6 +484,7 @@ export function ChatInput({
|
|||||||
onSelect={onModelSelect}
|
onSelect={onModelSelect}
|
||||||
onConfigure={onConfigureModels}
|
onConfigure={onConfigureModels}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|||||||
@@ -943,7 +943,11 @@ export default function ChatPanel({
|
|||||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image
|
<Image
|
||||||
src="/favicon.ico"
|
src={
|
||||||
|
darkMode
|
||||||
|
? "/favicon-white.svg"
|
||||||
|
: "/favicon.ico"
|
||||||
|
}
|
||||||
alt="Next AI Drawio"
|
alt="Next AI Drawio"
|
||||||
width={isMobile ? 24 : 28}
|
width={isMobile ? 24 : 28}
|
||||||
height={isMobile ? 24 : 28}
|
height={isMobile ? 24 : 28}
|
||||||
@@ -1071,6 +1075,7 @@ export default function ChatPanel({
|
|||||||
models={modelConfig.models}
|
models={modelConfig.models}
|
||||||
selectedModelId={modelConfig.selectedModelId}
|
selectedModelId={modelConfig.selectedModelId}
|
||||||
onModelSelect={modelConfig.setSelectedModelId}
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
|
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
@@ -412,11 +413,7 @@ export function ModelConfigDialog({
|
|||||||
setSelectedProviderId(
|
setSelectedProviderId(
|
||||||
provider.id,
|
provider.id,
|
||||||
)
|
)
|
||||||
setValidationStatus(
|
setValidationStatus("idle")
|
||||||
provider.validated
|
|
||||||
? "success"
|
|
||||||
: "idle",
|
|
||||||
)
|
|
||||||
setShowApiKey(false)
|
setShowApiKey(false)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -555,6 +552,20 @@ export function ModelConfigDialog({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteConfirmOpen(true)
|
||||||
|
}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.deleteProvider
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Section */}
|
{/* Configuration Section */}
|
||||||
@@ -1416,24 +1427,6 @@ export function ModelConfigDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<div className="pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirmOpen(true)
|
|
||||||
}
|
|
||||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
{
|
|
||||||
dict.modelConfig
|
|
||||||
.deleteProvider
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</>
|
</>
|
||||||
@@ -1455,10 +1448,23 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
|
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
|
||||||
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-between">
|
||||||
<Key className="h-3 w-3" />
|
<div className="flex items-center gap-2">
|
||||||
{dict.modelConfig.apiKeyStored}
|
<Switch
|
||||||
</p>
|
checked={modelConfig.showUnvalidatedModels}
|
||||||
|
onCheckedChange={
|
||||||
|
modelConfig.setShowUnvalidatedModels
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
{dict.modelConfig.showUnvalidatedModels}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Key className="h-3 w-3" />
|
||||||
|
{dict.modelConfig.apiKeyStored}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Server,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
ModelSelectorContent,
|
ModelSelectorContent,
|
||||||
@@ -26,6 +33,7 @@ interface ModelSelectorProps {
|
|||||||
onSelect: (modelId: string | undefined) => void
|
onSelect: (modelId: string | undefined) => void
|
||||||
onConfigure: () => void
|
onConfigure: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
showUnvalidatedModels?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map our provider names to models.dev logo names
|
// Map our provider names to models.dev logo names
|
||||||
@@ -67,17 +75,20 @@ export function ModelSelector({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
showUnvalidatedModels = false,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
// Only show validated models in the selector
|
// Filter models based on showUnvalidatedModels setting
|
||||||
const validatedModels = useMemo(
|
const displayModels = useMemo(() => {
|
||||||
() => models.filter((m) => m.validated === true),
|
if (showUnvalidatedModels) {
|
||||||
[models],
|
return models
|
||||||
)
|
}
|
||||||
|
return models.filter((m) => m.validated === true)
|
||||||
|
}, [models, showUnvalidatedModels])
|
||||||
const groupedModels = useMemo(
|
const groupedModels = useMemo(
|
||||||
() => groupModelsByProvider(validatedModels),
|
() => groupModelsByProvider(displayModels),
|
||||||
[validatedModels],
|
[displayModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Find selected model for display
|
// Find selected model for display
|
||||||
@@ -126,7 +137,7 @@ export function ModelSelector({
|
|||||||
/>
|
/>
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<ModelSelectorEmpty>
|
<ModelSelectorEmpty>
|
||||||
{validatedModels.length === 0 && models.length > 0
|
{displayModels.length === 0 && models.length > 0
|
||||||
? dict.modelConfig.noVerifiedModels
|
? dict.modelConfig.noVerifiedModels
|
||||||
: dict.modelConfig.noModelsFound}
|
: dict.modelConfig.noModelsFound}
|
||||||
</ModelSelectorEmpty>
|
</ModelSelectorEmpty>
|
||||||
@@ -191,6 +202,16 @@ export function ModelSelector({
|
|||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
{model.modelId}
|
{model.modelId}
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
|
{model.validated !== true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
))}
|
))}
|
||||||
</ModelSelectorGroup>
|
</ModelSelectorGroup>
|
||||||
@@ -213,7 +234,9 @@ export function ModelSelector({
|
|||||||
</ModelSelectorGroup>
|
</ModelSelectorGroup>
|
||||||
{/* Info text */}
|
{/* Info text */}
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
{dict.modelConfig.onlyVerifiedShown}
|
{showUnvalidatedModels
|
||||||
|
? dict.modelConfig.allModelsShown
|
||||||
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
</div>
|
</div>
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ function SettingsContent({
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const changeLanguage = (lang: string) => {
|
const changeLanguage = (lang: string) => {
|
||||||
|
// Save locale to localStorage for persistence across restarts
|
||||||
|
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||||
|
|
||||||
const parts = pathname.split("/")
|
const parts = pathname.split("/")
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
parts[1] = lang
|
parts[1] = lang
|
||||||
|
|||||||
267
docs/Cloudflare_Deploy.md
Normal file
267
docs/Cloudflare_Deploy.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Deploy on Cloudflare Workers
|
||||||
|
|
||||||
|
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
|
||||||
|
|
||||||
|
- Global edge deployment
|
||||||
|
- Very low latency
|
||||||
|
- Free `workers.dev` hosting
|
||||||
|
- Full Next.js ISR support via R2 (optional)
|
||||||
|
|
||||||
|
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
|
||||||
|
>
|
||||||
|
> - Use **GitHub Codespaces** (works perfectly)
|
||||||
|
> - OR use **WSL (Linux)**
|
||||||
|
>
|
||||||
|
> Pure Windows builds may fail due to WASM file path issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A **Cloudflare account** (free tier works for basic deployment)
|
||||||
|
2. **Node.js 18+**
|
||||||
|
3. **Wrangler CLI** installed (dev dependency is fine):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D wrangler
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Cloudflare login:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx wrangler login
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Configure environment variables
|
||||||
|
|
||||||
|
Cloudflare uses a different file for local testing.
|
||||||
|
|
||||||
|
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in your API keys and configuration.
|
||||||
|
|
||||||
|
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in the same values there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Choose your deployment type
|
||||||
|
|
||||||
|
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
||||||
|
|
||||||
|
If you don't need ISR caching, you can deploy without R2:
|
||||||
|
|
||||||
|
**1. Use simple `open-next.config.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({})
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08",
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip to **Step 4**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Deploy WITH R2 (Full ISR Support)
|
||||||
|
|
||||||
|
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
||||||
|
|
||||||
|
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
||||||
|
|
||||||
|
- Go to **Storage & Databases → R2**
|
||||||
|
- Click **Create bucket**
|
||||||
|
- Name it: `next-inc-cache`
|
||||||
|
|
||||||
|
**2. Configure `open-next.config.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({
|
||||||
|
incrementalCache: r2IncrementalCache,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Configure `wrangler.jsonc` (with R2):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08",
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "next-inc-cache"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Register a workers.dev subdomain (first-time only)
|
||||||
|
|
||||||
|
Before your first deployment, you need a workers.dev subdomain.
|
||||||
|
|
||||||
|
**Option 1: Via Cloudflare Dashboard (Recommended)**
|
||||||
|
|
||||||
|
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
||||||
|
|
||||||
|
**Option 2: During deploy**
|
||||||
|
|
||||||
|
When you run `npm run deploy`, Wrangler may prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Would you like to register a workers.dev subdomain? (Y/n)
|
||||||
|
```
|
||||||
|
|
||||||
|
Type `Y` and choose a subdomain name.
|
||||||
|
|
||||||
|
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Deploy to Cloudflare
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
What the script does:
|
||||||
|
|
||||||
|
- Builds the Next.js app
|
||||||
|
- Converts it to a Cloudflare Worker via OpenNext
|
||||||
|
- Uploads static assets
|
||||||
|
- Publishes the Worker
|
||||||
|
|
||||||
|
Your app will be available at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<worker-name>.<your-subdomain>.workers.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common issues & fixes
|
||||||
|
|
||||||
|
### `You need to register a workers.dev subdomain`
|
||||||
|
|
||||||
|
**Cause:** No workers.dev subdomain registered for your account.
|
||||||
|
|
||||||
|
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `Please enable R2 through the Cloudflare Dashboard`
|
||||||
|
|
||||||
|
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
|
||||||
|
|
||||||
|
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
||||||
|
|
||||||
|
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
|
||||||
|
|
||||||
|
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `Can't set compatibility date in the future`
|
||||||
|
|
||||||
|
**Cause:** `compatibility_date` in wrangler config is set to a future date.
|
||||||
|
|
||||||
|
**Fix:** Change `compatibility_date` to today or an earlier date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Windows error: `resvg.wasm?module` (ENOENT)
|
||||||
|
|
||||||
|
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
|
||||||
|
|
||||||
|
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: Preview locally
|
||||||
|
|
||||||
|
Preview the Worker locally before deploying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Feature | Without R2 | With R2 |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| Cost | Free | Requires payment method |
|
||||||
|
| ISR Caching | No | Yes |
|
||||||
|
| Static Pages | Yes | Yes |
|
||||||
|
| API Routes | Yes | Yes |
|
||||||
|
| Setup Complexity | Simple | Moderate |
|
||||||
|
|
||||||
|
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
| Scenario | URL Value |
|
| Scenario | URL Value |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| Localhost | `http://localhost:8080` |
|
| Localhost | `http://localhost:8080` |
|
||||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
|
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
|
||||||
|
|
||||||
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
|
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
|
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
```
|
```
|
||||||
|
|||||||
47
electron.d.ts
vendored
47
electron.d.ts
vendored
@@ -2,6 +2,29 @@
|
|||||||
* Type declarations for Electron API exposed via preload script
|
* 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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
@@ -23,7 +46,29 @@ declare global {
|
|||||||
/** Save data to file via save dialog */
|
/** Save data to file via save dialog */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
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 {}
|
export { ConfigPreset, ApplyPresetResult }
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
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
|
* Build and set the application menu
|
||||||
@@ -9,6 +24,13 @@ export function buildAppMenu(): void {
|
|||||||
Menu.setApplicationMenu(menu)
|
Menu.setApplicationMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the menu (call this when presets change)
|
||||||
|
*/
|
||||||
|
export function rebuildAppMenu(): void {
|
||||||
|
buildAppMenu()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template
|
* Get the menu template
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +46,15 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Settings...",
|
||||||
|
accelerator: "CmdOrCtrl+,",
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
|
showSettingsWindow(win || undefined)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
{ role: "services" },
|
{ role: "services" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
@@ -38,7 +69,22 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [isMac ? { role: "close" } : { role: "quit" }],
|
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
|
// Edit menu
|
||||||
@@ -83,6 +129,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Configuration menu with presets
|
||||||
|
template.push(buildConfigMenu())
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Window",
|
label: "Window",
|
||||||
@@ -123,3 +172,70 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
|
|
||||||
return template
|
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
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
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
|
import { getCurrentPresetEnv } from "./config-manager"
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import { createWindow, getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
@@ -22,12 +24,19 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
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"
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
let serverUrl: string | null = null
|
let serverUrl: string | null = null
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
registerSettingsWindowHandlers()
|
||||||
|
|
||||||
// Build application menu
|
// Build application menu
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|||||||
@@ -1,4 +1,43 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
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
|
* Register all IPC handlers
|
||||||
@@ -84,4 +123,90 @@ export function registerIpcHandlers(): void {
|
|||||||
return false
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { app } from "electron"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Port configuration
|
* Port configuration
|
||||||
|
* Using fixed ports to preserve localStorage across restarts
|
||||||
|
* (localStorage is origin-specific, so changing ports loses all saved data)
|
||||||
*/
|
*/
|
||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Production mode port range (will find first available)
|
// Production mode uses fixed port (61337) to preserve localStorage
|
||||||
production: {
|
// Falls back to sequential ports if unavailable
|
||||||
min: 10000,
|
production: 61337,
|
||||||
max: 65535,
|
// Maximum attempts to find an available port (fallback)
|
||||||
},
|
|
||||||
// Maximum attempts to find an available port
|
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,19 +36,11 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: finds a random available port
|
* - In production: uses fixed port (61337) to preserve localStorage
|
||||||
* - If a port was previously allocated, verifies it's still available
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
@@ -56,6 +48,9 @@ function getRandomPort(): number {
|
|||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
|
const preferredPort = isDev
|
||||||
|
? PORT_CONFIG.development
|
||||||
|
: PORT_CONFIG.production
|
||||||
|
|
||||||
// Try to reuse cached port if requested and available
|
// Try to reuse cached port if requested and available
|
||||||
if (reuseExisting && allocatedPort !== null) {
|
if (reuseExisting && allocatedPort !== null) {
|
||||||
@@ -69,29 +64,22 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDev) {
|
// Try preferred port first
|
||||||
// Development mode: use fixed port
|
if (await isPortAvailable(preferredPort)) {
|
||||||
const port = PORT_CONFIG.development
|
allocatedPort = preferredPort
|
||||||
const available = await isPortAvailable(port)
|
return preferredPort
|
||||||
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
|
console.warn(
|
||||||
for (let attempt = 0; attempt < PORT_CONFIG.maxAttempts; attempt++) {
|
`Preferred port ${preferredPort} is in use, finding alternative...`,
|
||||||
const port = isDev
|
)
|
||||||
? PORT_CONFIG.development + attempt + 1
|
|
||||||
: getRandomPort()
|
|
||||||
|
|
||||||
const available = await isPortAvailable(port)
|
// Fallback: try sequential ports starting from preferred + 1
|
||||||
if (available) {
|
for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {
|
||||||
|
const port = preferredPort + attempt
|
||||||
|
if (await isPortAvailable(port)) {
|
||||||
allocatedPort = port
|
allocatedPort = port
|
||||||
console.log(`Allocated port: ${port}`)
|
console.log(`Allocated fallback port: ${port}`)
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
electron/main/settings-window.ts
Normal file
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
35
electron/preload/settings.ts
Normal file
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"),
|
||||||
|
})
|
||||||
116
electron/settings/index.html
Normal file
116
electron/settings/index.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<!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">
|
||||||
|
<div class="deprecation-notice">
|
||||||
|
<strong>⚠️ Deprecation Notice</strong>
|
||||||
|
<p>This settings panel will be removed in a future update.</p>
|
||||||
|
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
344
electron/settings/settings.css
Normal file
344
electron/settings/settings.css
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprecation-notice {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprecation-notice strong {
|
||||||
|
color: #856404;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprecation-notice p {
|
||||||
|
color: #856404;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.deprecation-notice {
|
||||||
|
background-color: #332701;
|
||||||
|
border-color: #665200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprecation-notice strong,
|
||||||
|
.deprecation-notice p {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
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
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
|
||||||
|
}
|
||||||
@@ -109,9 +109,11 @@ export interface UseModelConfigReturn {
|
|||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
selectedModel: FlattenedModel | undefined
|
selectedModel: FlattenedModel | undefined
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
|
showUnvalidatedModels: boolean
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setSelectedModelId: (modelId: string | undefined) => void
|
setSelectedModelId: (modelId: string | undefined) => void
|
||||||
|
setShowUnvalidatedModels: (show: boolean) => void
|
||||||
addProvider: (provider: ProviderName) => ProviderConfig
|
addProvider: (provider: ProviderName) => ProviderConfig
|
||||||
updateProvider: (
|
updateProvider: (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
@@ -160,6 +162,13 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setShowUnvalidatedModels = useCallback((show: boolean) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
showUnvalidatedModels: show,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addProvider = useCallback(
|
const addProvider = useCallback(
|
||||||
(provider: ProviderName): ProviderConfig => {
|
(provider: ProviderName): ProviderConfig => {
|
||||||
const newProvider = createProviderConfig(provider)
|
const newProvider = createProviderConfig(provider)
|
||||||
@@ -278,7 +287,9 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
models,
|
models,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
|
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
|
||||||
setSelectedModelId,
|
setSelectedModelId,
|
||||||
|
setShowUnvalidatedModels,
|
||||||
addProvider,
|
addProvider,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ try {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
||||||
|
* This is used as the Sort Key (SK) for per-day tracking
|
||||||
*/
|
*/
|
||||||
function getTodayInTimezone(): string {
|
function getTodayInTimezone(): string {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
@@ -61,8 +62,8 @@ interface QuotaCheckResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check all quotas and increment request count atomically.
|
* Check all quotas and increment request count atomically.
|
||||||
* Uses ConditionExpression to prevent race conditions.
|
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||||
* Returns which limit was exceeded if any.
|
* Each day automatically gets a new item - no explicit reset needed.
|
||||||
*/
|
*/
|
||||||
export async function checkAndIncrementRequest(
|
export async function checkAndIncrementRequest(
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -73,77 +74,33 @@ export async function checkAndIncrementRequest(
|
|||||||
return { allowed: true }
|
return { allowed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = getTodayInTimezone()
|
const pk = ip // User identifier (base64 IP)
|
||||||
|
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
|
||||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||||
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, try to reset counts if it's a new day (atomic day reset)
|
// Single atomic update - handles creation AND increment
|
||||||
// This will succeed only if lastResetDate < today or doesn't exist
|
// New day automatically creates new item (different SK)
|
||||||
try {
|
// Note: lastMinute/tpmCount are managed by recordTokenUsage only
|
||||||
await client.send(
|
|
||||||
new UpdateItemCommand({
|
|
||||||
TableName: TABLE,
|
|
||||||
Key: { PK: { S: `IP#${ip}` } },
|
|
||||||
// Reset all counts to 1/0 for the new day
|
|
||||||
UpdateExpression: `
|
|
||||||
SET lastResetDate = :today,
|
|
||||||
dailyReqCount = :one,
|
|
||||||
dailyTokenCount = :zero,
|
|
||||||
lastMinute = :minute,
|
|
||||||
tpmCount = :zero,
|
|
||||||
#ttl = :ttl
|
|
||||||
`,
|
|
||||||
// Only succeed if it's a new day (or new item)
|
|
||||||
ConditionExpression: `
|
|
||||||
attribute_not_exists(lastResetDate) OR lastResetDate < :today
|
|
||||||
`,
|
|
||||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
|
||||||
ExpressionAttributeValues: {
|
|
||||||
":today": { S: today },
|
|
||||||
":zero": { N: "0" },
|
|
||||||
":one": { N: "1" },
|
|
||||||
":minute": { S: currentMinute },
|
|
||||||
":ttl": { N: String(ttl) },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
// New day reset successful
|
|
||||||
return { allowed: true }
|
|
||||||
} catch (resetError: any) {
|
|
||||||
// If condition failed, it's the same day - continue to increment logic
|
|
||||||
if (!(resetError instanceof ConditionalCheckFailedException)) {
|
|
||||||
throw resetError // Re-throw unexpected errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same day - increment request count with limit checks
|
|
||||||
await client.send(
|
await client.send(
|
||||||
new UpdateItemCommand({
|
new UpdateItemCommand({
|
||||||
TableName: TABLE,
|
TableName: TABLE,
|
||||||
Key: { PK: { S: `IP#${ip}` } },
|
Key: {
|
||||||
// Increment request count, handle minute boundary for TPM
|
PK: { S: pk },
|
||||||
UpdateExpression: `
|
SK: { S: sk },
|
||||||
SET lastMinute = :minute,
|
},
|
||||||
tpmCount = if_not_exists(tpmCount, :zero),
|
UpdateExpression: "ADD reqCount :one",
|
||||||
#ttl = :ttl
|
|
||||||
ADD dailyReqCount :one
|
|
||||||
`,
|
|
||||||
// Check all limits before allowing increment
|
// Check all limits before allowing increment
|
||||||
|
// TPM check: allow if new minute OR under limit
|
||||||
ConditionExpression: `
|
ConditionExpression: `
|
||||||
lastResetDate = :today AND
|
(attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND
|
||||||
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
|
(attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND
|
||||||
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
|
|
||||||
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
||||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||||
`,
|
`,
|
||||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: {
|
||||||
":today": { S: today },
|
|
||||||
":zero": { N: "0" },
|
|
||||||
":one": { N: "1" },
|
":one": { N: "1" },
|
||||||
":minute": { S: currentMinute },
|
":minute": { S: currentMinute },
|
||||||
":ttl": { N: String(ttl) },
|
|
||||||
":reqLimit": { N: String(limits.requests || 999999) },
|
":reqLimit": { N: String(limits.requests || 999999) },
|
||||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||||
":tpmLimit": { N: String(limits.tpm || 999999) },
|
":tpmLimit": { N: String(limits.tpm || 999999) },
|
||||||
@@ -160,42 +117,39 @@ export async function checkAndIncrementRequest(
|
|||||||
const getResult = await client.send(
|
const getResult = await client.send(
|
||||||
new GetItemCommand({
|
new GetItemCommand({
|
||||||
TableName: TABLE,
|
TableName: TABLE,
|
||||||
Key: { PK: { S: `IP#${ip}` } },
|
Key: {
|
||||||
|
PK: { S: pk },
|
||||||
|
SK: { S: sk },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const item = getResult.Item
|
const item = getResult.Item
|
||||||
const storedDate = item?.lastResetDate?.S
|
|
||||||
const storedMinute = item?.lastMinute?.S
|
const storedMinute = item?.lastMinute?.S
|
||||||
const isNewDay = !storedDate || storedDate < today
|
|
||||||
|
|
||||||
const dailyReqCount = isNewDay
|
const reqCount = Number(item?.reqCount?.N || 0)
|
||||||
? 0
|
const tokenCount = Number(item?.tokenCount?.N || 0)
|
||||||
: Number(item?.dailyReqCount?.N || 0)
|
|
||||||
const dailyTokenCount = isNewDay
|
|
||||||
? 0
|
|
||||||
: Number(item?.dailyTokenCount?.N || 0)
|
|
||||||
const tpmCount =
|
const tpmCount =
|
||||||
storedMinute !== currentMinute
|
storedMinute !== currentMinute
|
||||||
? 0
|
? 0
|
||||||
: Number(item?.tpmCount?.N || 0)
|
: Number(item?.tpmCount?.N || 0)
|
||||||
|
|
||||||
// Determine which limit was exceeded
|
// Determine which limit was exceeded
|
||||||
if (limits.requests > 0 && dailyReqCount >= limits.requests) {
|
if (limits.requests > 0 && reqCount >= limits.requests) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
type: "request",
|
type: "request",
|
||||||
error: "Daily request limit exceeded",
|
error: "Daily request limit exceeded",
|
||||||
used: dailyReqCount,
|
used: reqCount,
|
||||||
limit: limits.requests,
|
limit: limits.requests,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) {
|
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
type: "token",
|
type: "token",
|
||||||
error: "Daily token limit exceeded",
|
error: "Daily token limit exceeded",
|
||||||
used: dailyTokenCount,
|
used: tokenCount,
|
||||||
limit: limits.tokens,
|
limit: limits.tokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +164,7 @@ export async function checkAndIncrementRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Condition failed but no limit clearly exceeded - race condition edge case
|
// Condition failed but no limit clearly exceeded - race condition edge case
|
||||||
// Fail safe by allowing (could be a reset race)
|
// Fail safe by allowing (could be a TPM reset race)
|
||||||
console.warn(
|
console.warn(
|
||||||
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
||||||
)
|
)
|
||||||
@@ -233,7 +187,7 @@ export async function checkAndIncrementRequest(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Record token usage after response completes.
|
* Record token usage after response completes.
|
||||||
* Uses atomic operations to update both daily token count and TPM count.
|
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||||
* Handles minute boundaries atomically to prevent race conditions.
|
* Handles minute boundaries atomically to prevent race conditions.
|
||||||
*/
|
*/
|
||||||
export async function recordTokenUsage(
|
export async function recordTokenUsage(
|
||||||
@@ -244,24 +198,27 @@ export async function recordTokenUsage(
|
|||||||
if (!client || !TABLE) return
|
if (!client || !TABLE) return
|
||||||
if (!Number.isFinite(tokens) || tokens <= 0) return
|
if (!Number.isFinite(tokens) || tokens <= 0) return
|
||||||
|
|
||||||
|
const pk = ip // User identifier (base64 IP)
|
||||||
|
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
|
||||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||||
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to update assuming same minute (most common case)
|
// Try to update for same minute OR new item (most common cases)
|
||||||
// Uses condition to ensure we're in the same minute
|
// Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)
|
||||||
await client.send(
|
await client.send(
|
||||||
new UpdateItemCommand({
|
new UpdateItemCommand({
|
||||||
TableName: TABLE,
|
TableName: TABLE,
|
||||||
Key: { PK: { S: `IP#${ip}` } },
|
Key: {
|
||||||
|
PK: { S: pk },
|
||||||
|
SK: { S: sk },
|
||||||
|
},
|
||||||
UpdateExpression:
|
UpdateExpression:
|
||||||
"SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens",
|
"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens",
|
||||||
ConditionExpression: "lastMinute = :minute",
|
ConditionExpression:
|
||||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
"attribute_not_exists(lastMinute) OR lastMinute = :minute",
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: {
|
||||||
":minute": { S: currentMinute },
|
":minute": { S: currentMinute },
|
||||||
":tokens": { N: String(tokens) },
|
":tokens": { N: String(tokens) },
|
||||||
":ttl": { N: String(ttl) },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -272,14 +229,15 @@ export async function recordTokenUsage(
|
|||||||
await client.send(
|
await client.send(
|
||||||
new UpdateItemCommand({
|
new UpdateItemCommand({
|
||||||
TableName: TABLE,
|
TableName: TABLE,
|
||||||
Key: { PK: { S: `IP#${ip}` } },
|
Key: {
|
||||||
|
PK: { S: pk },
|
||||||
|
SK: { S: sk },
|
||||||
|
},
|
||||||
UpdateExpression:
|
UpdateExpression:
|
||||||
"SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens",
|
"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens",
|
||||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: {
|
||||||
":minute": { S: currentMinute },
|
":minute": { S: currentMinute },
|
||||||
":tokens": { N: String(tokens) },
|
":tokens": { N: String(tokens) },
|
||||||
":ttl": { N: String(ttl) },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -243,6 +243,9 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"serverDefault": "Server Default",
|
"serverDefault": "Server Default",
|
||||||
"configureModels": "Configure Models...",
|
"configureModels": "Configure Models...",
|
||||||
"onlyVerifiedShown": "Only verified models are shown"
|
"onlyVerifiedShown": "Only verified models are shown",
|
||||||
|
"showUnvalidatedModels": "Show unvalidated models",
|
||||||
|
"allModelsShown": "All models are shown (including unvalidated)",
|
||||||
|
"unvalidatedModelWarning": "This model has not been validated"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,9 @@
|
|||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"serverDefault": "サーバーデフォルト",
|
"serverDefault": "サーバーデフォルト",
|
||||||
"configureModels": "モデルを設定...",
|
"configureModels": "モデルを設定...",
|
||||||
"onlyVerifiedShown": "検証済みのモデルのみ表示"
|
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||||
|
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||||
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
|
"unvalidatedModelWarning": "このモデルは検証されていません"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,9 @@
|
|||||||
"default": "默认",
|
"default": "默认",
|
||||||
"serverDefault": "服务器默认",
|
"serverDefault": "服务器默认",
|
||||||
"configureModels": "配置模型...",
|
"configureModels": "配置模型...",
|
||||||
"onlyVerifiedShown": "仅显示已验证的模型"
|
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||||
|
"showUnvalidatedModels": "显示未验证的模型",
|
||||||
|
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||||
|
"unvalidatedModelWarning": "此模型尚未验证"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface MultiModelConfig {
|
|||||||
version: 1
|
version: 1
|
||||||
providers: ProviderConfig[]
|
providers: ProviderConfig[]
|
||||||
selectedModelId?: string // Currently selected model's UUID
|
selectedModelId?: string // Currently selected model's UUID
|
||||||
|
showUnvalidatedModels?: boolean // Show models that haven't been validated
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flattened model for dropdown display
|
// Flattened model for dropdown display
|
||||||
@@ -83,22 +84,24 @@ export const PROVIDER_INFO: Record<
|
|||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||||
openai: [
|
openai: [
|
||||||
// GPT-4o series (latest)
|
"gpt-5.2-pro",
|
||||||
|
"gpt-5.2-chat-latest",
|
||||||
|
"gpt-5.2",
|
||||||
|
"gpt-5.1-codex-mini",
|
||||||
|
"gpt-5.1-codex",
|
||||||
|
"gpt-5.1-chat-latest",
|
||||||
|
"gpt-5.1",
|
||||||
|
"gpt-5-pro",
|
||||||
|
"gpt-5",
|
||||||
|
"gpt-5-mini",
|
||||||
|
"gpt-5-nano",
|
||||||
|
"gpt-5-codex",
|
||||||
|
"gpt-5-chat-latest",
|
||||||
|
"gpt-4.1",
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"gpt-4.1-nano",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-4o-mini",
|
"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: [
|
anthropic: [
|
||||||
// Claude 4.5 series (latest)
|
// Claude 4.5 series (latest)
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ const nextConfig: NextConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
||||||
|
// Initialize OpenNext Cloudflare for local development only
|
||||||
|
// This must be a dynamic import to avoid loading workerd binary during builds
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
import("@opennextjs/cloudflare").then(
|
||||||
|
({ initOpenNextCloudflareForDev }) => {
|
||||||
|
initOpenNextCloudflareForDev()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
7
open-next.config.ts
Normal file
7
open-next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({
|
||||||
|
incrementalCache: r2IncrementalCache,
|
||||||
|
})
|
||||||
9887
package-lock.json
generated
9887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.6",
|
"version": "0.4.7",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -12,9 +12,13 @@
|
|||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"check": "biome ci",
|
"check": "biome ci",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
|
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||||
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||||
"electron:dev": "node scripts/electron-dev.mjs",
|
"electron:dev": "node scripts/electron-dev.mjs",
|
||||||
"electron:build": "npm run build && npm run electron:compile",
|
"electron:build": "npm run build && npm run electron:compile",
|
||||||
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external",
|
"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:start": "npx cross-env NODE_ENV=development npx electron .",
|
||||||
"electron:prepare": "node scripts/prepare-electron-build.mjs",
|
"electron:prepare": "node scripts/prepare-electron-build.mjs",
|
||||||
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
|
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
|
"@opennextjs/cloudflare": "1.14.7",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
@@ -62,7 +67,7 @@
|
|||||||
"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",
|
||||||
"lucide-react": "^0.483.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.7",
|
||||||
@@ -74,7 +79,7 @@
|
|||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -104,14 +109,15 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.0.5",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3",
|
||||||
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@openrouter/ai-sdk-provider": {
|
"@openrouter/ai-sdk-provider": {
|
||||||
|
|||||||
12
packages/mcp-server/package-lock.json
generated
12
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.5",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
@@ -481,9 +481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.25.0",
|
"version": "1.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||||
"integrity": "sha512-z0Zhn/LmQ3yz91dEfd5QgS7DpSjA4pk+3z2++zKgn5L6iDFM9QapsVoAQSbKLvlrFsZk9+ru6yHHWNq2lCYJKQ==",
|
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -1034,6 +1034,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -2027,6 +2028,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
2
public/_headers
Normal file
2
public/_headers
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/_next/static/*
|
||||||
|
Cache-Control: public,max-age=31536000,immutable
|
||||||
37
public/favicon-white.svg
Normal file
37
public/favicon-white.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1536.000000pt" height="1536.000000pt" viewBox="0 0 1536.000000 1536.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<g transform="translate(0.000000,1536.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#ffffff" stroke="none">
|
||||||
|
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431
|
||||||
|
-560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356
|
||||||
|
319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85
|
||||||
|
-17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529
|
||||||
|
-2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128
|
||||||
|
-82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303
|
||||||
|
-177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276
|
||||||
|
286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242
|
||||||
|
241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337
|
||||||
|
-25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22
|
||||||
|
-5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204
|
||||||
|
-383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72
|
||||||
|
-167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300
|
||||||
|
6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884
|
||||||
|
-15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534
|
||||||
|
c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154
|
||||||
|
11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448
|
||||||
|
-20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10
|
||||||
|
-47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293
|
||||||
|
-369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6
|
||||||
|
39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52
|
||||||
|
-149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415
|
||||||
|
20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50
|
||||||
|
118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0
|
||||||
|
535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539
|
||||||
|
376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70
|
||||||
|
290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
40
renovate.json
Normal file
40
renovate.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"schedule": ["after 10am on saturday"],
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"matchPackagePatterns": ["*"],
|
||||||
|
"groupName": "minor and patch dependencies",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"matchPackagePatterns": ["*"],
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@ai-sdk/*"],
|
||||||
|
"groupName": "AI SDK packages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@radix-ui/*"],
|
||||||
|
"groupName": "Radix UI packages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["electron", "electron-builder"],
|
||||||
|
"groupName": "Electron packages",
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
|
"groupName": "Core framework packages",
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Development script for running Electron with Next.js
|
* Development script for running Electron with Next.js
|
||||||
* 1. Starts Next.js dev server
|
* 1. Reads preset configuration (if exists)
|
||||||
* 2. Waits for it to be ready
|
* 2. Starts Next.js dev server with preset env vars
|
||||||
* 3. Compiles Electron TypeScript
|
* 3. Waits for it to be ready
|
||||||
* 4. Launches Electron
|
* 4. Compiles Electron TypeScript
|
||||||
|
* 5. Launches Electron
|
||||||
|
* 6. Watches for preset changes and restarts Next.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import { existsSync, readFileSync, watch } from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
@@ -18,6 +22,64 @@ const rootDir = path.join(__dirname, "..")
|
|||||||
const NEXT_PORT = 6002
|
const NEXT_PORT = 6002
|
||||||
const NEXT_URL = `http://localhost:${NEXT_PORT}`
|
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
|
* Wait for the Next.js server to be ready
|
||||||
*/
|
*/
|
||||||
@@ -67,14 +129,25 @@ function runCommand(command, args, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start Next.js dev server
|
* Start Next.js dev server with preset environment
|
||||||
*/
|
*/
|
||||||
function startNextServer() {
|
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"], {
|
const nextProcess = spawn("npm", ["run", "dev"], {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: true,
|
shell: true,
|
||||||
env: process.env,
|
env,
|
||||||
})
|
})
|
||||||
|
|
||||||
nextProcess.on("error", (err) => {
|
nextProcess.on("error", (err) => {
|
||||||
@@ -90,9 +163,12 @@ function startNextServer() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("🚀 Starting Electron development environment...\n")
|
console.log("🚀 Starting Electron development environment...\n")
|
||||||
|
|
||||||
// Start Next.js dev server
|
// Load preset configuration
|
||||||
|
const presetEnv = loadPresetConfig()
|
||||||
|
|
||||||
|
// Start Next.js dev server with preset env
|
||||||
console.log("1. Starting Next.js development server...")
|
console.log("1. Starting Next.js development server...")
|
||||||
const nextProcess = startNextServer()
|
let nextProcess = startNextServer(presetEnv)
|
||||||
|
|
||||||
// Wait for Next.js to be ready
|
// Wait for Next.js to be ready
|
||||||
try {
|
try {
|
||||||
@@ -127,14 +203,75 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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) => {
|
electronProcess.on("close", (code) => {
|
||||||
console.log(`\nElectron exited with code ${code}`)
|
console.log(`\nElectron exited with code ${code}`)
|
||||||
|
if (configWatcher) configWatcher.close()
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(code || 0)
|
process.exit(code || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
electronProcess.on("error", (err) => {
|
electronProcess.on("error", (err) => {
|
||||||
console.error("Electron error:", err)
|
console.error("Electron error:", err)
|
||||||
|
if (configWatcher) configWatcher.close()
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
@@ -142,6 +279,7 @@ async function main() {
|
|||||||
// Handle termination signals
|
// Handle termination signals
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
console.log("\n🛑 Shutting down...")
|
console.log("\n🛑 Shutting down...")
|
||||||
|
if (configWatcher) configWatcher.close()
|
||||||
electronProcess.kill()
|
electronProcess.kill()
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
23
wrangler.jsonc
Normal file
23
wrangler.jsonc
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08", // must be a today or past compatibility_date
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "next-inc-cache"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user