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