mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
837 lines
27 KiB
TypeScript
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
|