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