返回 oh-my-ppt
settings.tsx
根目录 / src / renderer / src / pages / settings.tsx
1 import { useEffect, useMemo, useState } from 'react'
2 import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/Tabs'
3 import { useSettingsStore } from '../store'
4 import { useToastStore } from '../store'
5 import { useLang } from '../i18n'
6 import type { ImageModelConfig, ImageModelProvider, ModelConfig } from '../lib/ipc'
7 import {
8 CONFIGURABLE_MODEL_TIMEOUT_PROFILES,
9 type ConfigurableModelTimeoutProfile,
10 modelTimeoutMsToSeconds,
11 resolveModelTimeoutMs
12 } from '@shared/model-timeout.js'
13 import { AdvancedSettingsTab } from '../components/settings/AdvancedSettingsTab'
14 import { GeneralSettingsTab } from '../components/settings/GeneralSettingsTab'
15 import { ImageModelConfigDialog } from '../components/settings/ImageModelConfigDialog'
16 import { ImageModelSettingsTab } from '../components/settings/ImageModelSettingsTab'
17 import { ModelConfigDialog } from '../components/settings/ModelConfigDialog'
18 import { ModelSettingsTab } from '../components/settings/ModelSettingsTab'
19 import {
20 IMAGE_PROVIDER_OPTIONS,
21 createDefaultImageModelConfig,
22 createEmptyImageModelForm,
23 createEmptyModelForm,
24 createImageModelForm,
25 createModelForm,
26 readJsonObject,
27 stringifyJsonObject
28 } from '../components/settings/model-settings-utils'
29 import type { ImageModelForm, ModelForm } from '../components/settings/types'
30
31 const createTimeoutSeconds = (
32 timeouts?: Partial<Record<ConfigurableModelTimeoutProfile, number>>
33 ): Record<ConfigurableModelTimeoutProfile, string> =>
34 Object.fromEntries(
35 CONFIGURABLE_MODEL_TIMEOUT_PROFILES.map((profile) => [
36 profile,
37 String(modelTimeoutMsToSeconds(timeouts?.[profile], profile))
38 ])
39 ) as Record<ConfigurableModelTimeoutProfile, string>
40
41 const normalizeModelMaxTokens = (value: string): number => {
42 const parsed = Number(value.trim())
43 if (!Number.isFinite(parsed) || parsed <= 0) return 4096
44 return Math.max(256, Math.min(16384, Math.floor(parsed)))
45 }
46
47 const createModelVerificationSignature = (form: ModelForm): string =>
48 JSON.stringify({
49 provider: form.provider,
50 model: form.model.trim(),
51 apiKey: form.apiKey.trim(),
52 baseUrl: form.baseUrl.trim(),
53 maxTokens: normalizeModelMaxTokens(form.maxTokens),
54 disableTemperature: form.disableTemperature,
55 thinkingParameterMode: form.thinkingParameterMode
56 })
57
58 export function SettingsPage(): React.JSX.Element {
59 const {
60 modelConfigs,
61 imageModelConfigs,
62 fetchSettings,
63 saveSettings,
64 upsertModelConfig,
65 upsertImageModelConfig,
66 setActiveModelConfig,
67 setActiveImageModelConfig,
68 deleteModelConfig,
69 deleteImageModelConfig,
70 setVerificationMessage,
71 verifyApiKey,
72 verifyImageModel,
73 chooseStoragePath
74 } = useSettingsStore()
75 const { success, error, warning, info } = useToastStore()
76 const { lang, setLang, t } = useLang()
77 const [storagePath, setStoragePath] = useState(
78 () => useSettingsStore.getState().settings?.storagePath || ''
79 )
80 const [modelDialogOpen, setModelDialogOpen] = useState(false)
81 const [modelForm, setModelForm] = useState<ModelForm>(() => createEmptyModelForm())
82 const [imageModelDialogOpen, setImageModelDialogOpen] = useState(false)
83 const [imageModelForm, setImageModelForm] = useState<ImageModelForm>(() =>
84 createEmptyImageModelForm()
85 )
86 const [timeoutSeconds, setTimeoutSeconds] = useState<
87 Record<ConfigurableModelTimeoutProfile, string>
88 >(() => createTimeoutSeconds(useSettingsStore.getState().settings?.timeouts))
89 const [savingModel, setSavingModel] = useState(false)
90 const [verifiedModelSignature, setVerifiedModelSignature] = useState<string | null>(null)
91 const [savingTimeouts, setSavingTimeouts] = useState(false)
92 const [proxyUrl, setProxyUrl] = useState(
93 () => useSettingsStore.getState().settings?.proxyUrl || ''
94 )
95 const [verifying, setVerifying] = useState(false)
96 const [verifyingImageModel, setVerifyingImageModel] = useState(false)
97 const [activatingId, setActivatingId] = useState<string | null>(null)
98 const [activatingImageId, setActivatingImageId] = useState<string | null>(null)
99 const [deletingId, setDeletingId] = useState<string | null>(null)
100 const [deletingImageId, setDeletingImageId] = useState<string | null>(null)
101
102 useEffect(() => {
103 let active = true
104 const loadSettings = async (): Promise<void> => {
105 await fetchSettings()
106 if (!active) return
107 const nextSettings = useSettingsStore.getState().settings
108 setStoragePath(nextSettings?.storagePath || '')
109 setTimeoutSeconds(createTimeoutSeconds(nextSettings?.timeouts))
110 setProxyUrl(nextSettings?.proxyUrl || '')
111 }
112 void loadSettings()
113 return () => {
114 active = false
115 }
116 }, [fetchSettings])
117
118 useEffect(() => {
119 if (!modelDialogOpen) return
120 const handleKeyDown = (event: KeyboardEvent): void => {
121 if (event.key === 'Escape' && !savingModel) {
122 setModelDialogOpen(false)
123 }
124 }
125 document.addEventListener('keydown', handleKeyDown)
126 return () => {
127 document.removeEventListener('keydown', handleKeyDown)
128 }
129 }, [modelDialogOpen, savingModel])
130
131 const activeModelConfig = modelConfigs.find((config) => config.active)
132 const activeImageModelConfig = imageModelConfigs.find((config) => config.active)
133 const modelVerificationSignature = createModelVerificationSignature(modelForm)
134 const modelVerified = verifiedModelSignature === modelVerificationSignature
135 const timeoutFields: Array<{
136 profile: ConfigurableModelTimeoutProfile
137 label: string
138 hint: string
139 min: number
140 }> = useMemo(
141 () => [
142 {
143 profile: 'planning',
144 label: t('settings.timeoutPlanning'),
145 hint: t('settings.timeoutPlanningHint'),
146 min: 120
147 },
148 {
149 profile: 'design',
150 label: t('settings.timeoutDesign'),
151 hint: t('settings.timeoutDesignHint'),
152 min: 120
153 },
154 {
155 profile: 'agent',
156 label: t('settings.timeoutAgent'),
157 hint: t('settings.timeoutAgentHint'),
158 min: 300
159 },
160 {
161 profile: 'document',
162 label: t('settings.timeoutDocument'),
163 hint: t('settings.timeoutDocumentHint'),
164 min: 300
165 }
166 ],
167 [t]
168 )
169
170 const openCreateModelDialog = (): void => {
171 setModelForm(createEmptyModelForm(modelConfigs.length === 0))
172 setVerifiedModelSignature(null)
173 setVerificationMessage(null)
174 setModelDialogOpen(true)
175 }
176
177 const openEditModelDialog = (config: ModelConfig): void => {
178 setModelForm(createModelForm(config))
179 setVerifiedModelSignature(null)
180 setVerificationMessage(null)
181 setModelDialogOpen(true)
182 }
183
184 const updateModelForm = (patch: Partial<ModelForm>): void => {
185 setModelForm((form) => ({ ...form, ...patch }))
186 setVerificationMessage(null)
187 }
188
189 const openCreateImageModelDialog = (): void => {
190 setImageModelForm(createEmptyImageModelForm(imageModelConfigs.length === 0))
191 setVerificationMessage(null)
192 setImageModelDialogOpen(true)
193 }
194
195 const openEditImageModelDialog = (config: ImageModelConfig): void => {
196 setImageModelForm(createImageModelForm(config))
197 setVerificationMessage(null)
198 setImageModelDialogOpen(true)
199 }
200
201 const updateImageModelForm = (patch: Partial<ImageModelForm>): void => {
202 setImageModelForm((form) => ({ ...form, ...patch }))
203 setVerificationMessage(null)
204 }
205
206 const handleImageProviderChange = (value: string): void => {
207 if (!IMAGE_PROVIDER_OPTIONS.some((item) => item.value === value)) return
208 const provider = value as ImageModelProvider
209 setImageModelForm((form) => ({
210 ...form,
211 provider,
212 modelConfig:
213 provider !== form.provider ? createDefaultImageModelConfig(provider) : form.modelConfig
214 }))
215 setVerificationMessage(null)
216 }
217
218 const normalizeImageModelConfigForSave = (): string | null => {
219 const modelConfig = readJsonObject(imageModelForm.modelConfig)
220 return modelConfig ? stringifyJsonObject(modelConfig) : null
221 }
222
223 const handleSaveModel = async (): Promise<void> => {
224 if (!modelForm.name.trim()) {
225 warning(t('settings.fillModelName'))
226 return
227 }
228 if (!modelForm.model.trim()) {
229 warning(t('settings.fillModel'))
230 return
231 }
232 if (!modelForm.apiKey.trim()) {
233 warning(t('settings.fillApiKey'))
234 return
235 }
236 if (!modelVerified) {
237 warning(t('settings.verifyBeforeSave'))
238 return
239 }
240
241 setSavingModel(true)
242 setVerificationMessage(null)
243 try {
244 const id = await upsertModelConfig({
245 id: modelForm.id,
246 name: modelForm.name.trim(),
247 provider: modelForm.provider,
248 model: modelForm.model.trim(),
249 apiKey: modelForm.apiKey.trim(),
250 baseUrl: modelForm.baseUrl.trim(),
251 maxTokens: normalizeModelMaxTokens(modelForm.maxTokens),
252 disableTemperature: modelForm.disableTemperature,
253 thinkingParameterMode: modelForm.thinkingParameterMode,
254 active: modelForm.active
255 })
256 const saveError = useSettingsStore.getState().verificationMessage
257 if (!id || saveError) {
258 error(t('settings.saveFailed'), { description: saveError || t('common.retryLater') })
259 return
260 }
261 setModelDialogOpen(false)
262 setVerifiedModelSignature(null)
263 success(t('settings.modelSaved'), { description: t('settings.modelSavedDescription') })
264 } finally {
265 setSavingModel(false)
266 }
267 }
268
269 const handleSaveImageModel = async (): Promise<void> => {
270 if (!imageModelForm.name.trim()) {
271 warning(t('settings.fillModelName'))
272 return
273 }
274 const modelConfig = normalizeImageModelConfigForSave()
275 if (!modelConfig) {
276 warning(t('settings.fillImageModelConfig'))
277 return
278 }
279
280 setSavingModel(true)
281 setVerificationMessage(null)
282 try {
283 const id = await upsertImageModelConfig({
284 id: imageModelForm.id,
285 name: imageModelForm.name.trim(),
286 provider: imageModelForm.provider,
287 active: imageModelForm.active,
288 modelConfig
289 })
290 const saveError = useSettingsStore.getState().verificationMessage
291 if (!id || saveError) {
292 error(t('settings.saveFailed'), { description: saveError || t('common.retryLater') })
293 return
294 }
295 setImageModelDialogOpen(false)
296 success(t('settings.imageModelSaved'), {
297 description: t('settings.imageModelSavedDescription')
298 })
299 } finally {
300 setSavingModel(false)
301 }
302 }
303
304 const handleTimeoutChange = (profile: ConfigurableModelTimeoutProfile, value: string): void => {
305 setTimeoutSeconds((current) => ({
306 ...current,
307 [profile]: value
308 }))
309 setVerificationMessage(null)
310 }
311
312 const handleSaveAdvanced = async (): Promise<void> => {
313 const timeoutEntries = timeoutFields.map((field) => {
314 const value = timeoutSeconds[field.profile].trim()
315 const num = Number(value)
316 return { field, num, value }
317 })
318 const invalidTimeout = timeoutEntries.find(({ field, num, value }) => {
319 return !value || !Number.isFinite(num) || num < field.min || num > 3600
320 })
321 if (invalidTimeout) {
322 warning(`${invalidTimeout.field.label}: ${t('settings.timeoutPlaceholder')}`)
323 return
324 }
325
326 setSavingTimeouts(true)
327 setVerificationMessage(null)
328 try {
329 await saveSettings({
330 timeouts: Object.fromEntries(
331 timeoutEntries.map(({ field, num }) => [field.profile, Math.round(num) * 1000])
332 ) as Record<ConfigurableModelTimeoutProfile, number>,
333 proxyUrl: proxyUrl.trim()
334 })
335 const saveError = useSettingsStore.getState().verificationMessage
336 if (saveError) {
337 error(t('settings.saveFailed'), { description: saveError })
338 return
339 }
340 success(t('settings.saved'), {
341 description: t('settings.savedDescription')
342 })
343 } finally {
344 setSavingTimeouts(false)
345 }
346 }
347
348 const handleVerify = async (): Promise<void> => {
349 if (!modelForm.apiKey.trim()) {
350 warning(t('settings.fillApiKey'))
351 return
352 }
353 if (!modelForm.model.trim()) {
354 warning(t('settings.fillModel'))
355 return
356 }
357
358 setVerifying(true)
359 setVerificationMessage(null)
360 setVerifiedModelSignature(null)
361 const nextVerifiedSignature = createModelVerificationSignature(modelForm)
362 try {
363 const valid = await verifyApiKey(
364 modelForm.provider,
365 modelForm.apiKey,
366 modelForm.model,
367 modelForm.baseUrl,
368 normalizeModelMaxTokens(modelForm.maxTokens),
369 modelForm.disableTemperature,
370 modelForm.thinkingParameterMode,
371 resolveModelTimeoutMs(undefined, 'verify')
372 )
373 const verifyMessage = useSettingsStore.getState().verificationMessage
374 if (valid) {
375 setVerifiedModelSignature(nextVerifiedSignature)
376 success(t('settings.verifyPassed'), {
377 description: verifyMessage || t('settings.verifyPassedDescription')
378 })
379 } else {
380 error(t('settings.verifyFailed'), {
381 description: verifyMessage || t('settings.verifyFailedDescription')
382 })
383 }
384 } finally {
385 setVerifying(false)
386 }
387 }
388
389 const handleVerifyImageModel = async (): Promise<void> => {
390 const modelConfig = normalizeImageModelConfigForSave()
391 if (!modelConfig) {
392 warning(t('settings.fillImageModelConfig'))
393 return
394 }
395
396 setVerifyingImageModel(true)
397 setVerificationMessage(null)
398 try {
399 const valid = await verifyImageModel(
400 imageModelForm.provider,
401 modelConfig
402 )
403 const verifyMessage = useSettingsStore.getState().verificationMessage
404 if (valid) {
405 success(t('settings.verifyPassed'), {
406 description: verifyMessage || t('settings.verifyPassedDescription')
407 })
408 } else {
409 error(t('settings.verifyFailed'), {
410 description: verifyMessage || t('settings.verifyFailedDescription')
411 })
412 }
413 } finally {
414 setVerifyingImageModel(false)
415 }
416 }
417
418 const handleActivateModel = async (id: string): Promise<void> => {
419 setActivatingId(id)
420 setVerificationMessage(null)
421 try {
422 await setActiveModelConfig(id)
423 const activateError = useSettingsStore.getState().verificationMessage
424 if (activateError) {
425 error(t('settings.activateModelFailed'), { description: activateError })
426 return
427 }
428 success(t('settings.activeModelUpdated'))
429 } finally {
430 setActivatingId(null)
431 }
432 }
433
434 const handleDeleteModel = async (config: ModelConfig): Promise<void> => {
435 if (!window.confirm(t('settings.deleteModelConfirm', { name: config.name }))) return
436 setDeletingId(config.id)
437 setVerificationMessage(null)
438 try {
439 await deleteModelConfig(config.id)
440 const deleteError = useSettingsStore.getState().verificationMessage
441 if (deleteError) {
442 error(t('settings.deleteModelFailed'), { description: deleteError })
443 return
444 }
445 info(t('settings.modelDeleted'))
446 } finally {
447 setDeletingId(null)
448 }
449 }
450
451 const handleActivateImageModel = async (id: string): Promise<void> => {
452 setActivatingImageId(id)
453 setVerificationMessage(null)
454 try {
455 await setActiveImageModelConfig(id)
456 const activateError = useSettingsStore.getState().verificationMessage
457 if (activateError) {
458 error(t('settings.activateImageModelFailed'), { description: activateError })
459 return
460 }
461 success(t('settings.activeImageModelUpdated'))
462 } finally {
463 setActivatingImageId(null)
464 }
465 }
466
467 const handleDeleteImageModel = async (config: ImageModelConfig): Promise<void> => {
468 if (!window.confirm(t('settings.deleteModelConfirm', { name: config.name }))) return
469 setDeletingImageId(config.id)
470 setVerificationMessage(null)
471 try {
472 await deleteImageModelConfig(config.id)
473 const deleteError = useSettingsStore.getState().verificationMessage
474 if (deleteError) {
475 error(t('settings.deleteImageModelFailed'), { description: deleteError })
476 return
477 }
478 info(t('settings.imageModelDeleted'))
479 } finally {
480 setDeletingImageId(null)
481 }
482 }
483
484 const handleChoosePath = async (): Promise<void> => {
485 const path = await chooseStoragePath()
486 const pathError = useSettingsStore.getState().storagePathError
487 if (pathError) {
488 error(t('settings.choosePathFailed'), { description: pathError })
489 return
490 }
491 if (path) {
492 setVerificationMessage(null)
493 await saveSettings({ storagePath: path })
494 const saveError = useSettingsStore.getState().verificationMessage
495 if (saveError) {
496 error(t('settings.saveFailed'), { description: saveError })
497 return
498 }
499 setStoragePath(path)
500 info(t('settings.storagePathUpdated'), { description: path })
501 }
502 }
503
504 return (
505 <div className="mx-auto max-w-4xl p-6">
506 <div className="mb-5">
507 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
508 {t('settings.eyebrow')}
509 </p>
510 <h1 className="organic-serif mt-2 text-[32px] font-semibold leading-none text-[#3e4a32]">
511 {t('settings.title')}
512 </h1>
513 </div>
514
515 <Tabs defaultValue="general">
516 <TabsList>
517 <TabsTrigger value="general">{t('settings.generalTab')}</TabsTrigger>
518 <TabsTrigger value="model">{t('settings.modelTab')}</TabsTrigger>
519 <TabsTrigger value="imageModel">{t('settings.imageModelTab')}</TabsTrigger>
520 <TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
521 </TabsList>
522
523 <TabsContent value="general">
524 <GeneralSettingsTab
525 lang={lang}
526 storagePath={storagePath}
527 t={t}
528 onChoosePath={() => void handleChoosePath()}
529 onLangChange={setLang}
530 />
531 </TabsContent>
532
533 <TabsContent value="model">
534 <ModelSettingsTab
535 activeModelConfig={activeModelConfig}
536 activatingId={activatingId}
537 deletingId={deletingId}
538 modelConfigs={modelConfigs}
539 t={t}
540 onActivate={(configId) => void handleActivateModel(configId)}
541 onCreate={openCreateModelDialog}
542 onDelete={(config) => void handleDeleteModel(config)}
543 onEdit={openEditModelDialog}
544 />
545 </TabsContent>
546
547 <TabsContent value="imageModel">
548 <ImageModelSettingsTab
549 activeImageModelConfig={activeImageModelConfig}
550 activatingImageId={activatingImageId}
551 deletingImageId={deletingImageId}
552 imageModelConfigs={imageModelConfigs}
553 t={t}
554 onActivate={(configId) => void handleActivateImageModel(configId)}
555 onCreate={openCreateImageModelDialog}
556 onDelete={(config) => void handleDeleteImageModel(config)}
557 onEdit={openEditImageModelDialog}
558 />
559 </TabsContent>
560
561 <TabsContent value="advanced">
562 <AdvancedSettingsTab
563 proxyUrl={proxyUrl}
564 savingTimeouts={savingTimeouts}
565 timeoutFields={timeoutFields}
566 timeoutSeconds={timeoutSeconds}
567 t={t}
568 onProxyUrlChange={(value) => {
569 setProxyUrl(value)
570 setVerificationMessage(null)
571 }}
572 onSaveAdvanced={() => void handleSaveAdvanced()}
573 onTimeoutChange={handleTimeoutChange}
574 />
575 </TabsContent>
576 </Tabs>
577
578 <ModelConfigDialog
579 form={modelForm}
580 open={modelDialogOpen}
581 saving={savingModel}
582 verifying={verifying}
583 verified={modelVerified}
584 t={t}
585 onClose={() => setModelDialogOpen(false)}
586 onFormChange={updateModelForm}
587 onSave={() => void handleSaveModel()}
588 onVerify={() => void handleVerify()}
589 />
590
591 <ImageModelConfigDialog
592 form={imageModelForm}
593 open={imageModelDialogOpen}
594 saving={savingModel}
595 verifying={verifyingImageModel}
596 t={t}
597 onClose={() => setImageModelDialogOpen(false)}
598 onFormChange={updateImageModelForm}
599 onProviderChange={handleImageProviderChange}
600 onSave={() => void handleSaveImageModel()}
601 onVerify={() => void handleVerifyImageModel()}
602 />
603 </div>
604 )
605 }
606
606 lines Plain Text