Files
chatgpt-on-wechat/desktop/src/renderer/src/pages/settings/ModelsTab.tsx

837 lines
27 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
MessageSquare,
Eye,
Image as ImageIcon,
Mic,
Volume2,
Database,
Search as SearchIcon,
Plus,
Check,
Loader2,
Pencil,
Eye as EyeIcon,
EyeOff,
} from 'lucide-react'
import { t, localizedLabel } from '../../i18n'
import apiClient from '../../api/client'
import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState } from '../../types'
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
import CapabilityCard from './CapabilityCard'
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
interface ModelsTabProps {
baseUrl: string
}
const REPLY_MODES: { value: 'off' | 'voice_if_voice' | 'always'; key: string }[] = [
{ value: 'off', key: 'models_tts_mode_off' },
{ value: 'voice_if_voice', key: 'models_tts_mode_if_voice' },
{ value: 'always', key: 'models_tts_mode_always' },
]
const ModelsTab: React.FC<ModelsTabProps> = ({ baseUrl }) => {
const [data, setData] = useState<ModelsData | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string>('') // capability key currently saving
const [statusMap, setStatusMap] = useState<Record<string, string>>({})
const load = useCallback(async () => {
try {
const fresh = await apiClient.getModels()
setData(fresh)
} catch (e) {
console.error('Failed to load models:', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
load()
}, [baseUrl, load])
const flash = (key: string, msg: string) => {
setStatusMap((m) => ({ ...m, [key]: msg }))
setTimeout(() => setStatusMap((m) => ({ ...m, [key]: '' })), 2000)
}
// Run a models action, then refresh and flash a status for the given key.
const run = async (key: string, action: Parameters<typeof apiClient.modelsAction>[0]) => {
setBusy(key)
try {
const res = await apiClient.modelsAction(action)
if (res.status === 'success') {
await load()
flash(key, t('config_saved'))
} else {
flash(key, (res.message as string) || t('config_save_error'))
}
} catch {
flash(key, t('config_save_error'))
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
)
}
if (!data) {
return <div className="text-center py-20 text-content-tertiary">{t('config_save_error')}</div>
}
const caps = data.capabilities
return (
<div className="grid gap-5">
<VendorSection data={data} onChanged={load} statusMap={statusMap} flash={flash} />
{/* Chat */}
<CapabilityCard
icon={MessageSquare}
title={t('models_cap_chat')}
subtitle={t('models_cap_chat_sub')}
capKey="chat"
state={caps.chat}
data={data}
allowCustomModel
busy={busy === 'chat'}
status={statusMap.chat}
onSave={(p, m) => run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })}
/>
{/* Vision */}
<CapabilityCard
icon={Eye}
title={t('models_cap_vision')}
subtitle={t('models_cap_vision_sub')}
capKey="vision"
state={caps.vision}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'vision'}
status={statusMap.vision}
onSave={(p, m) => run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })}
>
<FallbackHint state={caps.vision} data={data} />
</CapabilityCard>
{/* Image */}
<CapabilityCard
icon={ImageIcon}
title={t('models_cap_image')}
subtitle={t('models_cap_image_sub')}
capKey="image"
state={caps.image}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'image'}
status={statusMap.image}
onSave={(p, m) => run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })}
>
<FallbackHint state={caps.image} data={data} />
</CapabilityCard>
{/* ASR */}
<CapabilityCard
icon={Mic}
title={t('models_cap_asr')}
subtitle={t('models_cap_asr_sub')}
capKey="asr"
state={caps.asr}
data={data}
allowAuto
autoLabel={t('models_asr_auto')}
busy={busy === 'asr'}
status={statusMap.asr}
onSave={(p, m) => run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })}
/>
{/* TTS — bespoke (voice + reply mode) */}
<TtsCard
state={caps.tts}
data={data}
busy={busy === 'tts'}
status={statusMap.tts}
onSaveVoice={(p, m, v) =>
run('tts', { action: 'set_capability', capability: 'tts', provider_id: p, model: m, voice: v })
}
onSaveMode={(mode) => run('tts_mode', { action: 'set_voice_reply_mode', mode })}
modeStatus={statusMap.tts_mode}
modeBusy={busy === 'tts_mode'}
/>
{/* Embedding */}
<EmbeddingCard
state={caps.embedding}
data={data}
busy={busy === 'embedding'}
status={statusMap.embedding}
onSave={(p, m) => run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })}
/>
{/* Search — bespoke */}
<SearchCard
state={caps.search}
busy={busy === 'search'}
status={statusMap.search}
onSaveStrategy={(strategy, provider) =>
run('search', { action: 'set_capability', capability: 'search', strategy, provider })
}
onSaveBochaKey={(key) => run('search_key', { action: 'set_search_credential', api_key: key })}
keyStatus={statusMap.search_key}
keyBusy={busy === 'search_key'}
/>
</div>
)
}
// ============================================================
// Layer 1 — vendor credentials
// ============================================================
interface VendorSectionProps {
data: ModelsData
onChanged: () => Promise<void>
statusMap: Record<string, string>
flash: (key: string, msg: string) => void
}
const VendorSection: React.FC<VendorSectionProps> = ({ data, onChanged }) => {
// Edit an existing built-in vendor.
const [editing, setEditing] = useState<ModelProvider | null>(null)
// Add flow: open the vendor modal with a provider picker.
const [adding, setAdding] = useState(false)
// Custom provider modal: 'new' to create, or a provider to edit.
const [customEditing, setCustomEditing] = useState<ModelProvider | 'new' | null>(null)
const isCustomCard = (p: ModelProvider) => p.is_custom && !!p.custom_name
// Unified grid: configured built-ins + all custom provider cards (web parity).
const shown = data.providers.filter((p) => p.configured || isCustomCard(p))
return (
<Card icon={<Database size={16} />} title={t('models_vendors')} subtitle={t('models_vendors_sub')}>
{shown.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 rounded-btn border border-dashed border-default">
<p className="text-sm text-content-tertiary">{t('models_no_vendor')}</p>
<button
onClick={() => setAdding(true)}
className="mt-3 inline-flex items-center gap-1 px-3 py-1.5 rounded-btn text-xs font-medium bg-accent-soft text-accent hover:bg-accent-soft/70 cursor-pointer transition-colors"
>
<Plus size={12} /> {t('models_add_vendor')}
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{shown.map((p) =>
isCustomCard(p) ? (
<VendorChip key={p.id} provider={p} onClick={() => setCustomEditing(p)} />
) : (
<VendorChip key={p.id} provider={p} onClick={() => setEditing(p)} />
)
)}
<button
onClick={() => setAdding(true)}
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-btn border border-dashed border-default text-content-tertiary hover:border-accent hover:text-accent cursor-pointer transition-colors text-sm"
>
<Plus size={14} /> {t('models_add_vendor')}
</button>
</div>
)}
<VendorModal
provider={editing}
addMode={adding}
data={data}
onClose={() => {
setEditing(null)
setAdding(false)
}}
onPickCustom={() => {
setAdding(false)
setCustomEditing('new')
}}
onSaved={onChanged}
/>
<CustomProviderModal target={customEditing} onClose={() => setCustomEditing(null)} onSaved={onChanged} />
</Card>
)
}
const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => (
<button
onClick={onClick}
className="group flex items-center gap-2.5 px-3 py-2.5 rounded-btn border border-default bg-inset hover:border-accent cursor-pointer transition-colors text-left"
>
<span className="flex-shrink-0 w-7 h-7 rounded-lg bg-surface-2 text-content-secondary flex items-center justify-center text-xs font-bold">
{(localizedLabel(provider.label) || provider.id || '?').slice(0, 1).toUpperCase()}
</span>
<span className="flex-1 min-w-0 text-sm font-medium text-content truncate">{localizedLabel(provider.label)}</span>
<Pencil size={12} className="flex-shrink-0 text-content-tertiary group-hover:text-accent transition-colors" />
</button>
)
const CUSTOM_PICK = '__custom_new__'
const VendorModal: React.FC<{
provider: ModelProvider | null
addMode: boolean
data: ModelsData
onClose: () => void
onPickCustom: () => void
onSaved: () => Promise<void>
}> = ({ provider, addMode, data, onClose, onPickCustom, onSaved }) => {
const open = !!provider || addMode
// In add-mode the user first picks a built-in provider; that selection
// becomes the effective provider whose key/base fields we edit. Exclude ALL
// custom providers (named or empty placeholder): custom vendors are added via
// the single "custom vendor" option below, so an empty custom placeholder must
// not show up here as a second, duplicate custom entry.
const builtins = useMemo(() => data.providers.filter((p) => !p.is_custom), [data.providers])
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
const [pickId, setPickId] = useState('')
const effective: ModelProvider | undefined = provider || builtins.find((p) => p.id === pickId)
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [keyVisible, setKeyVisible] = useState(false)
const [apiBase, setApiBase] = useState('')
const [saving, setSaving] = useState(false)
// Load fields whenever the effective provider changes.
useEffect(() => {
if (!open) return
const init = provider || (addMode ? firstUnconfigured : undefined)
setPickId(provider ? provider.id : firstUnconfigured?.id || '')
setApiKey(init?.api_key_masked || '')
setApiBase(init?.api_base || '')
setKeyDirty(false)
setKeyVisible(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, addMode, open])
if (!open) return null
const pickOptions = [
...builtins.map((p) => ({
value: p.id,
label: localizedLabel(p.label),
hint: p.configured ? t('models_configured') : undefined,
})),
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
]
const onPick = (val: string) => {
if (val === CUSTOM_PICK) {
onPickCustom()
return
}
setPickId(val)
const p = builtins.find((x) => x.id === val)
setApiKey(p?.api_key_masked || '')
setApiBase(p?.api_base || '')
setKeyDirty(false)
}
const hasBase = !!effective?.api_base_field
const save = async () => {
if (!effective) return
setSaving(true)
try {
const payload: { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string } = {
action: 'set_provider',
provider_id: effective.id,
}
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
if (hasBase) payload.api_base = apiBase
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const clear = async () => {
if (!effective || !confirm(t('models_clear_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_provider', provider_id: effective.id })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
title={addMode ? t('models_add_vendor') : localizedLabel(effective?.label)}
onClose={onClose}
footer={
<>
{!addMode && effective?.configured && (
<Btn variant="danger" onClick={clear} disabled={saving}>
{t('models_clear')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !effective}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
{addMode && (
<Field label={t('models_provider')}>
<Dropdown value={pickId} options={pickOptions} onChange={onPick} />
</Field>
)}
<Field label="API Key">
<div className="relative">
<TextInput
type={keyVisible ? 'text' : 'password'}
className="pr-10 font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onBlur={() => {
if (!keyDirty) setApiKey(effective?.api_key_masked || '')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
<button
type="button"
onClick={() => setKeyVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{keyVisible ? <EyeOff size={14} /> : <EyeIcon size={14} />}
</button>
</div>
</Field>
{hasBase && (
<Field label="API Base">
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={effective?.api_base_placeholder || 'https://...'}
/>
</Field>
)}
</Modal>
)
}
const CustomProviderModal: React.FC<{
target: ModelProvider | 'new' | null
onClose: () => void
onSaved: () => Promise<void>
}> = ({ target, onClose, onSaved }) => {
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
const [apiBase, setApiBase] = useState('')
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!target) return
if (editing) {
setName(editing.custom_name || localizedLabel(editing.label))
setApiBase(editing.api_base || '')
setApiKey(editing.api_key_masked || '')
} else {
setName('')
setApiBase('')
setApiKey('')
}
setKeyDirty(false)
}, [target, editing])
if (!target) return null
const save = async () => {
if (!name.trim()) return
setSaving(true)
try {
const payload: {
action: 'set_custom_provider'
name: string
id?: string
api_base: string
api_key?: string
} = {
action: 'set_custom_provider',
name: name.trim(),
api_base: apiBase.trim(),
}
if (editing) payload.id = editing.custom_id
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!editing || !confirm(t('models_delete_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_custom_provider', id: editing.custom_id || '' })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={!!target}
title={editing ? t('models_edit_custom') : t('models_add_custom')}
onClose={onClose}
footer={
<>
{editing && (
<Btn variant="danger" onClick={remove} disabled={saving}>
{t('models_delete')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !name.trim() || (!editing && !apiBase.trim())}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label={t('models_custom_name')}>
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="My Provider" />
</Field>
<Field label="API Base" hint={t('models_custom_base_hint')}>
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder="https://...../v1"
/>
</Field>
<Field label="API Key">
<TextInput
type="text"
className="font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
</Field>
</Modal>
)
}
// ============================================================
// Bespoke capability cards
// ============================================================
const FallbackHint: React.FC<{ state: CapabilityState; data: ModelsData }> = ({ state, data }) => {
if (!state.fallback_provider && !state.fallback_model) return null
const label = providerLabel(data, state.fallback_provider || '')
return (
<p className="text-xs text-content-tertiary">
{t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''}
</p>
)
}
const TtsCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSaveVoice: (provider: string, model: string, voice: string) => void
onSaveMode: (mode: 'off' | 'voice_if_voice' | 'always') => void
modeStatus?: string
modeBusy: boolean
}> = ({ state, data, busy, status, onSaveVoice, onSaveMode, modeStatus, modeBusy }) => {
const [provider, setProvider] = useState(state.current_provider || '')
const [model, setModel] = useState(state.current_model || '')
const [voice, setVoice] = useState(state.current_voice || '')
const [mode, setMode] = useState<'off' | 'voice_if_voice' | 'always'>(state.reply_mode || 'off')
const providerOptions = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
const modelOptions = normEntries(state.provider_models?.[provider]).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const voiceOptions = resolveVoices(provider, model, state.provider_voices).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const handleProvider = (id: string) => {
setProvider(id)
const first = normEntries(state.provider_models?.[id])[0]
const fm = first?.value || ''
setModel(fm)
setVoice(resolveVoices(id, fm, state.provider_voices)[0]?.value || '')
}
const handleModel = (m: string) => {
setModel(m)
setVoice(resolveVoices(provider, m, state.provider_voices)[0]?.value || '')
}
return (
<Card icon={<Volume2 size={16} />} title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}>
<div className="space-y-4">
{/* Reply mode — saved immediately */}
<Field label={t('models_tts_reply_mode')} hint={t('models_tts_reply_mode_hint')}>
<Dropdown
value={mode}
options={REPLY_MODES.map((m) => ({ value: m.value, label: t(m.key) }))}
onChange={(v) => {
const next = v as 'off' | 'voice_if_voice' | 'always'
setMode(next)
onSaveMode(next)
}}
disabled={modeBusy}
/>
{modeStatus && <span className="text-xs text-accent">{modeStatus}</span>}
</Field>
{mode !== 'off' && (
<>
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
</Field>
<Field label={t('models_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={handleModel}
/>
</Field>
{voiceOptions.length > 0 && (
<Field label={t('models_voice')}>
<Dropdown value={voice} options={voiceOptions} placeholder={t('models_select_voice')} onChange={setVoice} />
</Field>
)}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy}
onClick={() => onSaveVoice(provider, model, voice)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</>
)}
</div>
</Card>
)
}
const EmbeddingCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSave: (provider: string, model: string) => void
}> = ({ state, data, busy, status, onSave }) => (
<CapabilityCard
icon={Database}
title={t('models_cap_embedding')}
subtitle={t('models_cap_embedding_sub')}
capKey="embedding"
state={state}
data={data}
allowAuto
autoLabel={t('models_disabled')}
busy={busy}
status={status}
onSave={onSave}
>
{state.current_dim != null && (
<p className="text-xs text-content-tertiary">
{t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')}
</p>
)}
</CapabilityCard>
)
const SearchCard: React.FC<{
state: SearchCapabilityState
busy: boolean
status?: string
onSaveStrategy: (strategy: string, provider: string) => void
onSaveBochaKey: (key: string) => void
keyStatus?: string
keyBusy: boolean
}> = ({ state, busy, status, onSaveStrategy, onSaveBochaKey, keyStatus, keyBusy }) => {
const [strategy, setStrategy] = useState<string>(state.strategy || 'auto')
const [provider, setProvider] = useState<string>(state.fixed_provider || state.current_provider || '')
const [bochaOpen, setBochaOpen] = useState(false)
const providerOptions = useMemo(
() => state.providers.map((p) => ({ value: p.id, label: localizedLabel(p.label) })),
[state.providers]
)
const bocha = state.providers.find((p) => p.id === 'bocha')
return (
<Card icon={<SearchIcon size={16} />} title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}>
<div className="space-y-4">
<Field label={t('models_search_strategy')}>
<Dropdown
value={strategy}
options={[
{ value: 'auto', label: t('models_search_auto') },
{ value: 'fixed', label: t('models_search_fixed') },
]}
onChange={setStrategy}
/>
</Field>
{strategy === 'fixed' && (
<Field label={t('models_search_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={setProvider}
/>
</Field>
)}
<div className="flex items-center justify-between">
<button
onClick={() => setBochaOpen(true)}
className="text-xs text-accent hover:text-accent-hover cursor-pointer inline-flex items-center gap-1"
>
{t('models_search_bocha_key')}
{bocha?.configured && <Check size={12} />}
</button>
<div className="flex items-center gap-3">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy || (strategy === 'fixed' && !provider)}
onClick={() => onSaveStrategy(strategy, provider)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</div>
</div>
<BochaKeyModal
open={bochaOpen}
masked={bocha?.api_key_masked || ''}
busy={keyBusy}
status={keyStatus}
onClose={() => setBochaOpen(false)}
onSave={(k) => {
onSaveBochaKey(k)
setBochaOpen(false)
}}
/>
</Card>
)
}
const BochaKeyModal: React.FC<{
open: boolean
masked: string
busy: boolean
status?: string
onClose: () => void
onSave: (key: string) => void
}> = ({ open, masked, busy, onClose, onSave }) => {
const [key, setKey] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (open) {
setKey(masked)
setDirty(false)
}
}, [open, masked])
return (
<Modal
open={open}
title={t('models_search_bocha_key')}
onClose={onClose}
footer={
<>
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" disabled={busy} onClick={() => onSave(dirty && !MASK_RE.test(key) ? key : '')}>
{busy ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label="Bocha API Key" hint={t('models_search_bocha_hint')}>
<TextInput
className="font-mono"
value={key}
placeholder="sk-..."
onFocus={() => {
if (!dirty && MASK_RE.test(key)) setKey('')
}}
onChange={(e) => {
setKey(e.target.value)
setDirty(true)
}}
/>
</Field>
</Modal>
)
}
export default ModelsTab