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