返回 oh-my-ppt
ModelConfigDialog.tsx
根目录 / src / renderer / src / components / settings / ModelConfigDialog.tsx
1 import { useEffect, useMemo, useState } from 'react'
2 import { ChevronDown, CircleHelp, ListRestart, ShieldCheck, X } from 'lucide-react'
3 import { useSettingsStore } from '../../store'
4 import { useToastStore } from '../../store'
5 import { Button } from '../ui/Button'
6 import { Input } from '../ui/Input'
7 import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover'
8 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/Select'
9 import type { ModelForm, SettingsTranslate } from './types'
10
11 const MODEL_PROVIDER_LINKS = [
12 { label: 'DeepSeek', url: 'https://platform.deepseek.com' },
13 { label: 'Moonshot (Kimi)', url: 'https://platform.moonshot.cn' },
14 { label: 'GLM (智谱)', url: 'https://open.bigmodel.cn' },
15 { label: 'Qwen (通义千问)', url: 'https://bailian.console.aliyun.com/' },
16 { label: 'Doubao (豆包)', url: 'https://console.volcengine.com/ark' },
17 { label: 'Mimo (小米)', url: 'https://platform.xiaomimimo.com' },
18 { label: 'MiniMax', url: 'https://www.minimaxi.com/' },
19 { label: 'OpenAI', url: 'https://platform.openai.com' },
20 { label: 'Claude (Anthropic)', url: 'https://console.anthropic.com' },
21 { label: 'Google Gemini', url: 'https://ai.google.dev' }
22 ]
23
24 interface ModelConfigDialogProps {
25 form: ModelForm
26 open: boolean
27 saving: boolean
28 verified: boolean
29 verifying: boolean
30 t: SettingsTranslate
31 onClose: () => void
32 onFormChange: (patch: Partial<ModelForm>) => void
33 onSave: () => void
34 onVerify: () => void
35 }
36
37 export function ModelConfigDialog({
38 form,
39 open,
40 saving,
41 verified,
42 verifying,
43 t,
44 onClose,
45 onFormChange,
46 onSave,
47 onVerify
48 }: ModelConfigDialogProps): React.JSX.Element | null {
49 const fetchModelList = useSettingsStore((state) => state.fetchModelList)
50 const { error, warning, info } = useToastStore()
51 const [fetchingModels, setFetchingModels] = useState(false)
52 const [modelOptions, setModelOptions] = useState<string[]>([])
53 const [modelPickerOpen, setModelPickerOpen] = useState(false)
54 const [modelPickerFilter, setModelPickerFilter] = useState('')
55
56 // 连接信息(provider / base_url / api_key)变化后,已拉取的列表随之失效
57 useEffect(() => {
58 setModelOptions([])
59 setModelPickerOpen(false)
60 }, [form.provider, form.baseUrl, form.apiKey])
61
62 const filteredModelOptions = useMemo(() => {
63 const keyword = modelPickerFilter.trim().toLowerCase()
64 if (!keyword) return modelOptions
65 return modelOptions.filter((id) => id.toLowerCase().includes(keyword))
66 }, [modelOptions, modelPickerFilter])
67
68 const handleFetchModels = async (): Promise<void> => {
69 if (!form.apiKey.trim()) {
70 warning(t('settings.fillApiKey'))
71 return
72 }
73 setFetchingModels(true)
74 setModelPickerOpen(false)
75 try {
76 const { models, message } = await fetchModelList(form.provider, form.apiKey, form.baseUrl)
77 if (models.length === 0) {
78 setModelOptions([])
79 error(t('settings.fetchModelsFailed'), { description: message || undefined })
80 return
81 }
82 setModelOptions(models)
83 setModelPickerFilter('')
84 setModelPickerOpen(true)
85 info(t('settings.fetchModelsSuccess', { count: models.length }), {
86 description: t('settings.fetchModelsSuccessDescription')
87 })
88 } finally {
89 setFetchingModels(false)
90 }
91 }
92
93 const handlePickModel = (model: string): void => {
94 onFormChange({ model })
95 setModelPickerOpen(false)
96 }
97
98 if (!open) return null
99 return (
100 <div
101 className="fixed inset-0 z-50 flex items-center justify-center bg-[#2d291f]/42 p-4"
102 onMouseDown={(event) => {
103 if (event.target === event.currentTarget && !saving) onClose()
104 }}
105 >
106 <div className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-lg border border-[#d8ccb5]/85 bg-[#fffaf1] shadow-[0_24px_70px_rgba(53,44,32,0.28)]">
107 <div className="flex items-center justify-between border-b border-[#e3d8c5] px-5 py-3.5">
108 <h2 className="flex items-center gap-1.5 text-base font-semibold text-[#33402a]">
109 {form.id ? t('settings.editModel') : t('settings.addModel')}
110 <Popover>
111 <PopoverTrigger asChild>
112 <CircleHelp className="h-3.5 w-3.5 cursor-pointer text-muted-foreground/50 transition-colors hover:text-foreground" />
113 </PopoverTrigger>
114 <PopoverContent
115 side="bottom"
116 align="start"
117 className="w-auto max-w-xs border-[#d8cfbc]/80 bg-[#fffdf8] p-3"
118 >
119 <p className="mb-2 text-xs font-semibold text-[#3e4a32]">
120 {t('settings.modelHelpTitle')}
121 </p>
122 <div className="flex flex-wrap gap-1.5">
123 {MODEL_PROVIDER_LINKS.map((item) => (
124 <a
125 key={item.url}
126 href={item.url}
127 target="_blank"
128 rel="noopener noreferrer"
129 className="rounded-md border border-[#d8cfbc]/80 bg-[#f5efe2]/60 px-2 py-1 text-xs text-[#5b6b4d] transition-colors hover:border-[#96b77f]/60 hover:bg-[#e8f0de] hover:text-[#3e4a32]"
130 >
131 {item.label}
132 </a>
133 ))}
134 </div>
135 </PopoverContent>
136 </Popover>
137 </h2>
138 <Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
139 <X className="h-4 w-4" />
140 </Button>
141 </div>
142
143 <div className="space-y-3 p-5">
144 <div className="grid gap-2 sm:grid-cols-2">
145 <div>
146 <label className="mb-1 block text-sm font-medium">
147 {t('settings.providerPreset')}
148 </label>
149 <Select
150 value={form.provider}
151 onValueChange={(value) =>
152 onFormChange({
153 provider:
154 value === 'anthropic' ||
155 value === 'google' ||
156 value === 'openai-responses'
157 ? value
158 : 'openai'
159 })
160 }
161 >
162 <SelectTrigger className="h-8">
163 <SelectValue placeholder={t('settings.providerPlaceholder')} />
164 </SelectTrigger>
165 <SelectContent>
166 <SelectItem value="anthropic">Claude (Anthropic)</SelectItem>
167 <SelectItem value="openai">
168 {t('settings.providerOpenAIChatCompletions')}
169 </SelectItem>
170 <SelectItem value="openai-responses">
171 {t('settings.providerOpenAIResponses')}
172 </SelectItem>
173 <SelectItem value="google">Google Gemini</SelectItem>
174 </SelectContent>
175 </Select>
176 </div>
177 <div>
178 <label className="mb-1 block text-sm font-medium">{t('settings.modelName')}</label>
179 <Input
180 value={form.name}
181 onChange={(e) => onFormChange({ name: e.target.value })}
182 placeholder={t('settings.modelNamePlaceholder')}
183 className="h-8"
184 />
185 </div>
186 </div>
187
188 <div className="grid gap-2 sm:grid-cols-2">
189 <div>
190 <label className="mb-1 block text-sm font-medium">model</label>
191 <div className="flex gap-1.5">
192 <Input
193 placeholder={t('settings.modelPlaceholder')}
194 value={form.model}
195 onChange={(e) => onFormChange({ model: e.target.value })}
196 className="h-8"
197 />
198 <Button
199 type="button"
200 variant="secondary"
201 onClick={() => void handleFetchModels()}
202 disabled={saving || verifying || fetchingModels}
203 className="h-8 shrink-0 whitespace-nowrap border border-[#7ea06f]/45 px-2.5 text-xs"
204 >
205 <ListRestart className="mr-1 h-3.5 w-3.5" />
206 {fetchingModels ? t('settings.fetchingModels') : t('settings.fetchModels')}
207 </Button>
208 {modelOptions.length > 0 && (
209 <Popover open={modelPickerOpen} onOpenChange={setModelPickerOpen}>
210 <PopoverTrigger asChild>
211 <Button
212 type="button"
213 variant="secondary"
214 className="h-8 shrink-0 border border-[#7ea06f]/45 px-2"
215 aria-label={t('settings.modelPickerLabel')}
216 >
217 <ChevronDown className="h-3.5 w-3.5" />
218 </Button>
219 </PopoverTrigger>
220 <PopoverContent
221 align="end"
222 className="w-72 border-[#d8cfbc]/80 bg-[#fffdf8] p-2"
223 >
224 <Input
225 autoFocus
226 value={modelPickerFilter}
227 onChange={(e) => setModelPickerFilter(e.target.value)}
228 placeholder={t('settings.modelListSearchPlaceholder')}
229 className="h-8"
230 />
231 <div className="mt-1.5 max-h-56 overflow-y-auto">
232 {filteredModelOptions.length === 0 ? (
233 <p className="px-2 py-3 text-center text-xs text-muted-foreground">
234 {t('settings.modelListEmpty')}
235 </p>
236 ) : (
237 filteredModelOptions.map((model) => (
238 <button
239 key={model}
240 type="button"
241 onClick={() => handlePickModel(model)}
242 className="block w-full truncate rounded px-2 py-1.5 text-left text-xs text-[#3e4a32] transition-colors hover:bg-[#e8f0de]"
243 >
244 {model}
245 </button>
246 ))
247 )}
248 </div>
249 </PopoverContent>
250 </Popover>
251 )}
252 </div>
253 <p className="mt-1 text-[12px] text-muted-foreground/50">
254 {t('settings.modelHint')}
255 </p>
256 </div>
257 <div>
258 <label className="mb-1 block text-sm font-medium">base_url</label>
259 <Input
260 placeholder={t('settings.baseUrlPlaceholder')}
261 value={form.baseUrl}
262 onChange={(e) => onFormChange({ baseUrl: e.target.value })}
263 className="h-8"
264 />
265 <p className="mt-1 text-[12px] text-muted-foreground/50">
266 {form.provider === 'google'
267 ? t('settings.baseUrlHintGoogle')
268 : t('settings.baseUrlHint')}
269 </p>
270 </div>
271 </div>
272
273 <div>
274 <label className="mb-1 block text-sm font-medium">api_key</label>
275 <Input
276 type="password"
277 placeholder={t('settings.apiKeyPlaceholder', {
278 provider:
279 form.provider === 'openai' || form.provider === 'openai-responses'
280 ? 'OpenAI'
281 : form.provider === 'google'
282 ? 'Google'
283 : 'Claude'
284 })}
285 value={form.apiKey}
286 onChange={(e) => onFormChange({ apiKey: e.target.value })}
287 className="h-8"
288 />
289 <p className="mt-1 text-[12px] text-muted-foreground/50">
290 {t('settings.verifyHint')}
291 </p>
292 </div>
293
294 <div>
295 <label className="mb-1 block text-sm font-medium">max_tokens</label>
296 <Input
297 inputMode="numeric"
298 pattern="[0-9]*"
299 value={form.maxTokens}
300 onChange={(e) => onFormChange({ maxTokens: e.target.value })}
301 className="h-8"
302 />
303 <p className="mt-1 text-[12px] text-muted-foreground/50">
304 {t('settings.maxTokensHint')}
305 </p>
306 </div>
307
308 <div className="flex items-center justify-between gap-4 rounded-lg border border-[#e3d8c5] bg-[#fffdf8]/70 p-3">
309 <span className="min-w-0">
310 <span className="block text-sm font-medium">{t('settings.disableTemperature')}</span>
311 <span className="mt-0.5 block text-[12px] text-muted-foreground/50">
312 {t('settings.disableTemperatureHint')}
313 </span>
314 </span>
315 <input
316 type="checkbox"
317 aria-label={t('settings.disableTemperature')}
318 checked={form.disableTemperature}
319 onChange={(event) =>
320 onFormChange({ disableTemperature: event.currentTarget.checked })
321 }
322 className="h-4 w-4 shrink-0 cursor-pointer accent-[#5d7b4d]"
323 />
324 </div>
325
326 {form.provider === 'openai' && (
327 <div className="rounded-lg border border-[#e3d8c5] bg-[#fffdf8]/70 p-3">
328 <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
329 <span className="min-w-0">
330 <span className="block text-sm font-medium">
331 {t('settings.thinkingParameterMode')}
332 </span>
333 <span className="mt-0.5 block text-[12px] text-muted-foreground/50">
334 {t('settings.thinkingParameterModeHint')}
335 </span>
336 </span>
337 <Select
338 value={form.thinkingParameterMode}
339 onValueChange={(value) =>
340 onFormChange({
341 thinkingParameterMode: value === 'omit' ? 'omit' : 'auto'
342 })
343 }
344 >
345 <SelectTrigger className="h-8 w-full shrink-0 sm:w-[180px]">
346 <SelectValue />
347 </SelectTrigger>
348 <SelectContent>
349 <SelectItem value="auto">
350 {t('settings.thinkingParameterModeAuto')}
351 </SelectItem>
352 <SelectItem value="omit">
353 {t('settings.thinkingParameterModeOmit')}
354 </SelectItem>
355 </SelectContent>
356 </Select>
357 </div>
358 </div>
359 )}
360 </div>
361
362 <div className="flex justify-end gap-2 border-t border-[#e3d8c5] px-5 py-3.5">
363 <Button
364 type="button"
365 variant="ghost"
366 onClick={onClose}
367 disabled={saving}
368 className="h-8 px-3 text-xs"
369 >
370 {t('common.cancel')}
371 </Button>
372 <Button
373 variant="secondary"
374 onClick={onVerify}
375 disabled={saving || verifying}
376 className="h-8 border border-[#7ea06f]/45 px-3 text-xs"
377 >
378 <ShieldCheck className="mr-1 h-3.5 w-3.5" />
379 {verifying ? t('settings.verifying') : t('settings.verify')}
380 </Button>
381 <Button
382 onClick={onSave}
383 disabled={saving || verifying || !verified}
384 className="h-8 px-3 text-xs"
385 >
386 {saving ? t('common.saving') : t('settings.saveModel')}
387 </Button>
388 </div>
389 </div>
390 </div>
391 )
392 }
393
393 lines Plain Text