返回 oh-my-ppt
settings-handlers.ts
根目录 / src / main / config / settings-handlers.ts
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
465 lines TYPESCRIPT