mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 06:42:27 +08:00
Compare commits
1 Commits
v0.4.8
...
remove-ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e88b0711 |
@@ -147,14 +147,13 @@ 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. Open the app → **Menu → Configuration → Manage Presets**
|
2. Click the settings icon in the chat panel
|
||||||
3. Add your AI provider credentials
|
3. Add your AI provider credentials
|
||||||
4. Start creating diagrams!
|
4. Start creating diagrams!
|
||||||
|
|
||||||
|
|||||||
47
electron.d.ts
vendored
47
electron.d.ts
vendored
@@ -2,29 +2,6 @@
|
|||||||
* 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 */
|
||||||
@@ -46,29 +23,7 @@ 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 { ConfigPreset, ApplyPresetResult }
|
export {}
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
import {
|
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||||
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
|
||||||
@@ -24,13 +9,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -46,15 +24,6 @@ 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" },
|
||||||
@@ -69,22 +38,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [
|
submenu: [isMac ? { role: "close" } : { role: "quit" }],
|
||||||
...(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
|
||||||
@@ -129,9 +83,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
|
||||||
template.push(buildConfigMenu())
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Window",
|
label: "Window",
|
||||||
@@ -172,70 +123,3 @@ 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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
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,10 +1,8 @@
|
|||||||
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
|
||||||
@@ -24,19 +22,12 @@ 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,43 +1,4 @@
|
|||||||
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
|
||||||
@@ -123,90 +84,4 @@ 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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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"),
|
|
||||||
})
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -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 electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
|
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external",
|
||||||
"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",
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Development script for running Electron with Next.js
|
* Development script for running Electron with Next.js
|
||||||
* 1. Reads preset configuration (if exists)
|
* 1. Starts Next.js dev server
|
||||||
* 2. Starts Next.js dev server with preset env vars
|
* 2. Waits for it to be ready
|
||||||
* 3. Waits for it to be ready
|
* 3. Compiles Electron TypeScript
|
||||||
* 4. Compiles Electron TypeScript
|
* 4. Launches Electron
|
||||||
* 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"
|
||||||
|
|
||||||
@@ -22,64 +18,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -129,25 +67,14 @@ function runCommand(command, args, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start Next.js dev server with preset environment
|
* Start Next.js dev server
|
||||||
*/
|
*/
|
||||||
function startNextServer(presetEnv) {
|
function startNextServer() {
|
||||||
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,
|
env: process.env,
|
||||||
})
|
})
|
||||||
|
|
||||||
nextProcess.on("error", (err) => {
|
nextProcess.on("error", (err) => {
|
||||||
@@ -163,12 +90,9 @@ function startNextServer(presetEnv) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("🚀 Starting Electron development environment...\n")
|
console.log("🚀 Starting Electron development environment...\n")
|
||||||
|
|
||||||
// Load preset configuration
|
// Start Next.js dev server
|
||||||
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...")
|
||||||
let nextProcess = startNextServer(presetEnv)
|
const nextProcess = startNextServer()
|
||||||
|
|
||||||
// Wait for Next.js to be ready
|
// Wait for Next.js to be ready
|
||||||
try {
|
try {
|
||||||
@@ -203,75 +127,14 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -279,7 +142,6 @@ 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