返回 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 } 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
379 lines TYPESCRIPT