mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 14:52:28 +08:00
Compare commits
3 Commits
remove-ele
...
c5a04c9e50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a04c9e50 | ||
|
|
44c453403f | ||
|
|
9727aa5b39 |
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@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
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
|
||||||
@@ -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!
|
||||||
|
|
||||||
|
|||||||
@@ -412,11 +412,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 +551,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 +1426,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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -83,22 +83,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)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"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",
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user