| 1 | import { BrowserWindow, app, dialog, ipcMain } from 'electron' |
| 2 | import log from 'electron-log/main.js' |
| 3 | import { resolveModel } from '../agent-runtime/model' |
| 4 | import { applyProxy } from '../utils/proxy' |
| 5 | import type { IpcContext } from '../ipc/context' |
| 6 | import { |
| 7 | CONFIGURABLE_MODEL_TIMEOUT_PROFILES, |
| 8 | type ConfigurableModelTimeoutProfile, |
| 9 | resolveModelTimeoutMs |
| 10 | } from '@shared/model-timeout' |
| 11 | import { readAppLocale, uiText } from './locale-utils' |
| 12 | import { |
| 13 | OPENAI_RESPONSES_FORMAT_ERROR_EN, |
| 14 | OPENAI_RESPONSES_FORMAT_ERROR_ZH, |
| 15 | isOpenAIResponsesFormatError, |
| 16 | runWithModelTemperatureControl |
| 17 | } from '../agent-runtime/model' |
| 18 | import type { ModelUsagePeriod } from '@shared/model-usage' |
| 19 | import { normalizeThinkingParameterMode } from '@shared/model-config' |
| 20 | |
| 21 | const readGlobalTimeouts = ( |
| 22 | settings: Record<string, unknown> |
| 23 | ): Record<ConfigurableModelTimeoutProfile, number> => |
| 24 | Object.fromEntries( |
| 25 | CONFIGURABLE_MODEL_TIMEOUT_PROFILES.map((profile) => [ |
| 26 | profile, |
| 27 | resolveModelTimeoutMs(settings[`timeout_ms_${profile}`], profile) |
| 28 | ]) |
| 29 | ) as Record<ConfigurableModelTimeoutProfile, number> |
| 30 | |
| 31 | const VALID_PROVIDERS = ['anthropic', 'openai', 'openai-responses', 'google'] as const |
| 32 | type Provider = (typeof VALID_PROVIDERS)[number] |
| 33 | const normalizeProvider = (provider: unknown): Provider => |
| 34 | VALID_PROVIDERS.includes(provider as Provider) ? (provider as Provider) : 'openai' |
| 35 | const normalizeMaxTokens = (value: unknown): number => { |
| 36 | if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 4096 |
| 37 | return Math.max(256, Math.min(16384, Math.floor(value))) |
| 38 | } |
| 39 | |
| 40 | const normalizeVerifyErrorMessage = ( |
| 41 | error: unknown, |
| 42 | options: { |
| 43 | locale: 'zh' | 'en' |
| 44 | provider: unknown |
| 45 | } |
| 46 | ): string | null => { |
| 47 | const message = error instanceof Error ? error.message : '' |
| 48 | const unsupportedThinkingPattern = [ |
| 49 | /(?:unsupported|unknown|unrecognized|invalid|unexpected).*(?:argument|parameter|field).*thinking/i, |
| 50 | /thinking.*(?:unsupported|unknown|unrecognized|invalid)/i |
| 51 | ] |
| 52 | const isThinkingParameterError = |
| 53 | unsupportedThinkingPattern.some((pattern) => pattern.test(message)) || |
| 54 | (/(?:argument|parameter|field)/i.test(message) && /thinking/i.test(message)) |
| 55 | if (options.provider === 'openai-responses' && isOpenAIResponsesFormatError(error)) { |
| 56 | return uiText( |
| 57 | options.locale, |
| 58 | OPENAI_RESPONSES_FORMAT_ERROR_ZH, |
| 59 | OPENAI_RESPONSES_FORMAT_ERROR_EN |
| 60 | ) |
| 61 | } |
| 62 | if (options.provider === 'openai' && isThinkingParameterError) { |
| 63 | return uiText( |
| 64 | options.locale, |
| 65 | '当前模型不支持 thinking 参数,请在模型设置中改为“不发送 thinking 参数”。', |
| 66 | 'This model does not support the thinking parameter. In model settings, choose "Do not send thinking".' |
| 67 | ) |
| 68 | } |
| 69 | return message || null |
| 70 | } |
| 71 | |
| 72 | export function registerSettingsHandlers(ctx: IpcContext): void { |
| 73 | const { mainWindow, db, encryptApiKey, decryptApiKey } = ctx |
| 74 | |
| 75 | ipcMain.handle('app:getVersion', async () => { |
| 76 | return { version: app.getVersion() } |
| 77 | }) |
| 78 | |
| 79 | ipcMain.handle('settings:get', async () => { |
| 80 | log.info('[settings:get] requested') |
| 81 | const settings = await db.getAllSettings() |
| 82 | const storagePath = |
| 83 | typeof settings.storage_path === 'string' && settings.storage_path.trim().length > 0 |
| 84 | ? settings.storage_path.trim() |
| 85 | : '' |
| 86 | const proxyUrl = |
| 87 | typeof settings.proxy_url === 'string' && settings.proxy_url.trim().length > 0 |
| 88 | ? settings.proxy_url.trim() |
| 89 | : '' |
| 90 | return { |
| 91 | theme: settings.theme || 'light', |
| 92 | locale: settings.locale === 'en' ? 'en' : 'zh', |
| 93 | storagePath, |
| 94 | timeouts: readGlobalTimeouts(settings), |
| 95 | proxyUrl |
| 96 | } |
| 97 | }) |
| 98 | |
| 99 | ipcMain.handle('settings:listModelConfigs', async () => { |
| 100 | return (await db.listModelConfigs()).map((config) => ({ |
| 101 | id: config.id, |
| 102 | name: config.name, |
| 103 | provider: config.provider, |
| 104 | model: config.model, |
| 105 | apiKey: decryptApiKey(config.apiKey), |
| 106 | baseUrl: config.baseUrl, |
| 107 | maxTokens: config.maxTokens || 4096, |
| 108 | disableTemperature: config.disableTemperature === 1, |
| 109 | thinkingParameterMode: normalizeThinkingParameterMode(config.thinkingParameterMode), |
| 110 | active: config.active === 1, |
| 111 | createdAt: config.createdAt, |
| 112 | updatedAt: config.updatedAt |
| 113 | })) |
| 114 | }) |
| 115 | |
| 116 | ipcMain.handle('settings:getModelUsage', async (_event, requestedPeriod) => { |
| 117 | const period: ModelUsagePeriod = |
| 118 | requestedPeriod === 'today' || |
| 119 | requestedPeriod === '7d' || |
| 120 | requestedPeriod === '30d' || |
| 121 | requestedPeriod === 'all' |
| 122 | ? requestedPeriod |
| 123 | : '30d' |
| 124 | return db.getModelUsageStats(period) |
| 125 | }) |
| 126 | |
| 127 | ipcMain.handle('settings:validateUploadPrerequisites', async () => { |
| 128 | const locale = await readAppLocale(ctx) |
| 129 | const settings = await db.getAllSettings() |
| 130 | const storagePath = |
| 131 | typeof settings.storage_path === 'string' && settings.storage_path.trim().length > 0 |
| 132 | ? settings.storage_path.trim() |
| 133 | : '' |
| 134 | const activeModel = (await db.listModelConfigs()).find((config) => config.active === 1) |
| 135 | const hasModel = !!activeModel |
| 136 | const hasApiKey = typeof activeModel?.apiKey === 'string' && decryptApiKey(activeModel.apiKey).trim().length > 0 |
| 137 | const hasModelName = typeof activeModel?.model === 'string' && activeModel.model.trim().length > 0 |
| 138 | |
| 139 | const missing: Array<'storagePath' | 'activeModel' | 'apiKey' | 'model'> = [] |
| 140 | if (!storagePath) missing.push('storagePath') |
| 141 | if (!hasModel) missing.push('activeModel') |
| 142 | if (hasModel && !hasApiKey) missing.push('apiKey') |
| 143 | if (hasModel && !hasModelName) missing.push('model') |
| 144 | |
| 145 | return { |
| 146 | ready: missing.length === 0, |
| 147 | missing, |
| 148 | message: |
| 149 | missing.length === 0 |
| 150 | ? '' |
| 151 | : uiText( |
| 152 | locale, |
| 153 | '请先前往系统设置完成模型与存储目录配置。', |
| 154 | 'Please complete model and storage configuration in Settings first.' |
| 155 | ) |
| 156 | } |
| 157 | }) |
| 158 | |
| 159 | ipcMain.handle('settings:save', async (_event, settings) => { |
| 160 | log.info('[settings:save] received', { |
| 161 | hasStoragePath: |
| 162 | typeof settings?.storagePath === 'string' && settings.storagePath.trim().length > 0 |
| 163 | }) |
| 164 | if (settings.theme !== undefined) await db.setSetting('theme', settings.theme) |
| 165 | if (settings.locale === 'zh' || settings.locale === 'en') |
| 166 | await db.setSetting('locale', settings.locale) |
| 167 | if (typeof settings.storagePath === 'string' && settings.storagePath.trim().length > 0) { |
| 168 | await db.setStoragePath(settings.storagePath) |
| 169 | } |
| 170 | if (settings.timeouts && typeof settings.timeouts === 'object') { |
| 171 | const timeouts = settings.timeouts as Partial< |
| 172 | Record<ConfigurableModelTimeoutProfile, unknown> |
| 173 | > |
| 174 | for (const profile of CONFIGURABLE_MODEL_TIMEOUT_PROFILES) { |
| 175 | const value = timeouts[profile] |
| 176 | if (value !== undefined) { |
| 177 | await db.setSetting(`timeout_ms_${profile}`, resolveModelTimeoutMs(value, profile)) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | if ('proxyUrl' in settings) { |
| 182 | const nextProxy = |
| 183 | typeof settings.proxyUrl === 'string' ? settings.proxyUrl.trim() : '' |
| 184 | try { |
| 185 | applyProxy(nextProxy || undefined) |
| 186 | } catch (proxyError) { |
| 187 | log.error('[settings:save] failed to apply proxy', { |
| 188 | proxyUrl: nextProxy, |
| 189 | message: proxyError instanceof Error ? proxyError.message : String(proxyError) |
| 190 | }) |
| 191 | throw new Error( |
| 192 | uiText( |
| 193 | await readAppLocale(ctx), |
| 194 | `代理设置无效:${proxyError instanceof Error ? proxyError.message : '请检查地址格式'}`, |
| 195 | `Invalid proxy: ${proxyError instanceof Error ? proxyError.message : 'check the address format'}` |
| 196 | ) |
| 197 | ) |
| 198 | } |
| 199 | await db.setSetting('proxy_url', nextProxy) |
| 200 | } |
| 201 | return { success: true } |
| 202 | }) |
| 203 | |
| 204 | ipcMain.handle('settings:upsertModelConfig', async (_event, payload) => { |
| 205 | const locale = await readAppLocale(ctx) |
| 206 | const record = |
| 207 | payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} |
| 208 | const name = typeof record.name === 'string' ? record.name.trim() : '' |
| 209 | const provider = normalizeProvider(record.provider) |
| 210 | const model = typeof record.model === 'string' ? record.model.trim() : '' |
| 211 | const apiKey = typeof record.apiKey === 'string' ? record.apiKey.trim() : '' |
| 212 | const baseUrl = typeof record.baseUrl === 'string' ? record.baseUrl.trim() : '' |
| 213 | const id = |
| 214 | typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : undefined |
| 215 | if (!name) throw new Error(uiText(locale, '请填写模型名称。', 'Enter model name.')) |
| 216 | if (!model) throw new Error(uiText(locale, '请填写 model。', 'Enter model.')) |
| 217 | if (!apiKey) throw new Error(uiText(locale, '请填写 api_key。', 'Enter api_key.')) |
| 218 | const maxTokens = normalizeMaxTokens(record.maxTokens) |
| 219 | const thinkingParameterMode = normalizeThinkingParameterMode(record.thinkingParameterMode) |
| 220 | const savedId = await db.upsertModelConfig({ |
| 221 | id, |
| 222 | name, |
| 223 | provider, |
| 224 | model, |
| 225 | apiKey: encryptApiKey(apiKey), |
| 226 | baseUrl, |
| 227 | maxTokens, |
| 228 | disableTemperature: record.disableTemperature === true, |
| 229 | thinkingParameterMode, |
| 230 | active: record.active === true |
| 231 | }) |
| 232 | return { success: true, id: savedId } |
| 233 | }) |
| 234 | |
| 235 | ipcMain.handle('settings:setActiveModelConfig', async (_event, id) => { |
| 236 | const locale = await readAppLocale(ctx) |
| 237 | if (typeof id !== 'string' || id.trim().length === 0) { |
| 238 | throw new Error(uiText(locale, '模型配置 ID 不能为空。', 'Model config ID is required.')) |
| 239 | } |
| 240 | const modelId = id.trim() |
| 241 | try { |
| 242 | await db.setActiveModelConfig(modelId) |
| 243 | } catch (error) { |
| 244 | if (error instanceof Error && error.message === 'Model config does not exist') { |
| 245 | throw new Error(uiText(locale, '模型配置不存在。', 'Model config does not exist.')) |
| 246 | } |
| 247 | throw error |
| 248 | } |
| 249 | return { success: true } |
| 250 | }) |
| 251 | |
| 252 | ipcMain.handle('settings:deleteModelConfig', async (_event, id) => { |
| 253 | const locale = await readAppLocale(ctx) |
| 254 | if (typeof id !== 'string' || id.trim().length === 0) { |
| 255 | throw new Error(uiText(locale, '模型配置 ID 不能为空。', 'Model config ID is required.')) |
| 256 | } |
| 257 | try { |
| 258 | await db.deleteModelConfig(id.trim()) |
| 259 | } catch (error) { |
| 260 | if (error instanceof Error && error.message === 'Model config does not exist') { |
| 261 | throw new Error(uiText(locale, '模型配置不存在。', 'Model config does not exist.')) |
| 262 | } |
| 263 | throw error |
| 264 | } |
| 265 | return { success: true } |
| 266 | }) |
| 267 | |
| 268 | ipcMain.handle( |
| 269 | 'settings:verifyApiKey', |
| 270 | async ( |
| 271 | _event, |
| 272 | { |
| 273 | provider, |
| 274 | apiKey, |
| 275 | model, |
| 276 | baseUrl, |
| 277 | maxTokens, |
| 278 | disableTemperature, |
| 279 | thinkingParameterMode, |
| 280 | timeoutMs |
| 281 | } |
| 282 | ) => { |
| 283 | const locale = await readAppLocale(ctx) |
| 284 | const resolvedTimeoutMs = resolveModelTimeoutMs(timeoutMs, 'verify') |
| 285 | const resolvedMaxTokens = normalizeMaxTokens(maxTokens) |
| 286 | const resolvedThinkingParameterMode = normalizeThinkingParameterMode(thinkingParameterMode) |
| 287 | log.info('[settings:verifyApiKey] received', { |
| 288 | provider, |
| 289 | model, |
| 290 | hasApiKey: typeof apiKey === 'string' && apiKey.trim().length > 0, |
| 291 | baseUrl: typeof baseUrl === 'string' ? baseUrl : '', |
| 292 | maxTokens: resolvedMaxTokens, |
| 293 | thinkingParameterMode: resolvedThinkingParameterMode, |
| 294 | timeoutMs: resolvedTimeoutMs |
| 295 | }) |
| 296 | |
| 297 | if (typeof apiKey !== 'string' || apiKey.trim().length === 0) { |
| 298 | return { |
| 299 | valid: false, |
| 300 | message: uiText(locale, '请先填写 api_key。', 'Enter api_key first.') |
| 301 | } |
| 302 | } |
| 303 | if (typeof model !== 'string' || model.trim().length === 0) { |
| 304 | return { valid: false, message: uiText(locale, '请先填写 model。', 'Enter model first.') } |
| 305 | } |
| 306 | |
| 307 | try { |
| 308 | const client = runWithModelTemperatureControl( |
| 309 | { |
| 310 | disableTemperature: disableTemperature === true, |
| 311 | thinkingParameterMode: resolvedThinkingParameterMode |
| 312 | }, |
| 313 | () => |
| 314 | resolveModel( |
| 315 | provider, |
| 316 | apiKey.trim(), |
| 317 | model.trim(), |
| 318 | typeof baseUrl === 'string' ? baseUrl.trim() : '', |
| 319 | undefined, |
| 320 | resolvedMaxTokens, |
| 321 | ctx.modelRuntime |
| 322 | ) |
| 323 | ) |
| 324 | await client.invoke('Reply with OK.', { |
| 325 | signal: AbortSignal.timeout(resolvedTimeoutMs) |
| 326 | }) |
| 327 | log.info('[settings:verifyApiKey] success', { provider, model }) |
| 328 | return { valid: true, message: uiText(locale, '连接验证成功。', 'Connection verified.') } |
| 329 | } catch (error) { |
| 330 | const message = |
| 331 | normalizeVerifyErrorMessage(error, { locale, provider }) || |
| 332 | uiText( |
| 333 | locale, |
| 334 | '连接验证失败,请检查 api_key、model 或 base_url。', |
| 335 | 'Connection verification failed. Check api_key, model, or base_url.' |
| 336 | ) |
| 337 | log.error('[settings:verifyApiKey] failed', { |
| 338 | provider, |
| 339 | model, |
| 340 | baseUrl: typeof baseUrl === 'string' ? baseUrl : '', |
| 341 | message |
| 342 | }) |
| 343 | return { valid: false, message } |
| 344 | } |
| 345 | } |
| 346 | ) |
| 347 | |
| 348 | ipcMain.handle('settings:chooseStoragePath', async (event) => { |
| 349 | log.info('[settings:chooseStoragePath] received') |
| 350 | const targetWindow = |
| 351 | BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow |
| 352 | |
| 353 | try { |
| 354 | const settings = await db.getAllSettings() |
| 355 | const currentStoragePath = |
| 356 | typeof settings.storage_path === 'string' && settings.storage_path.trim().length > 0 |
| 357 | ? settings.storage_path.trim() |
| 358 | : '' |
| 359 | const result = await dialog.showOpenDialog(targetWindow, { |
| 360 | title: '选择 OhMYPPT 存储目录', |
| 361 | buttonLabel: '选择目录', |
| 362 | ...(currentStoragePath ? { defaultPath: currentStoragePath } : {}), |
| 363 | properties: ['openDirectory', 'createDirectory', 'promptToCreate'] |
| 364 | }) |
| 365 | if (!result.canceled && result.filePaths.length > 0) { |
| 366 | return { path: result.filePaths[0] } |
| 367 | } |
| 368 | return { path: null } |
| 369 | } catch (error) { |
| 370 | const message = |
| 371 | error instanceof Error && error.message.length > 0 |
| 372 | ? error.message |
| 373 | : '无法打开系统目录选择器。' |
| 374 | log.error('[settings:chooseStoragePath] failed', { message }) |
| 375 | return { path: null, error: message } |
| 376 | } |
| 377 | }) |
| 378 | } |
| 379 |