[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 (
+
+
+ {t('skills_loading')}
+
+ )
+ }
+ if (!data) {
+ return {t('config_save_error')}
+ }
+
+ const caps = data.capabilities
+
+ return (
+
+
+
+ {/* Chat */}
+ run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })}
+ />
+
+ {/* Vision */}
+ run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })}
+ >
+
+
+
+ {/* Image */}
+ run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })}
+ >
+
+
+
+ {/* ASR */}
+ run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })}
+ />
+
+ {/* TTS — bespoke (voice + reply mode) */}
+
+ 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 */}
+ run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })}
+ />
+
+ {/* Search — bespoke */}
+
+ 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'}
+ />
+
+ )
+}
+
+// ============================================================
+// Layer 1 — vendor credentials
+// ============================================================
+
+interface VendorSectionProps {
+ data: ModelsData
+ onChanged: () => Promise
+ statusMap: Record
+ flash: (key: string, msg: string) => void
+}
+
+const VendorSection: React.FC = ({ data, onChanged }) => {
+ // Edit an existing built-in vendor.
+ const [editing, setEditing] = useState(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(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 (
+ } title={t('models_vendors')} subtitle={t('models_vendors_sub')}>
+ {shown.length === 0 ? (
+
+
{t('models_no_vendor')}
+
+
+ ) : (
+
+ {shown.map((p) =>
+ isCustomCard(p) ? (
+
setCustomEditing(p)} />
+ ) : (
+ setEditing(p)} />
+ )
+ )}
+
+
+ )}
+
+ {
+ setEditing(null)
+ setAdding(false)
+ }}
+ onPickCustom={() => {
+ setAdding(false)
+ setCustomEditing('new')
+ }}
+ onSaved={onChanged}
+ />
+ setCustomEditing(null)} onSaved={onChanged} />
+
+ )
+}
+
+const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => (
+
+)
+
+const CUSTOM_PICK = '__custom_new__'
+
+const VendorModal: React.FC<{
+ provider: ModelProvider | null
+ addMode: boolean
+ data: ModelsData
+ onClose: () => void
+ onPickCustom: () => void
+ onSaved: () => Promise
+}> = ({ 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.
+ const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [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 (
+
+ {!addMode && effective?.configured && (
+
+ {t('models_clear')}
+
+ )}
+
+ {t('config_cancel')}
+
+
+ {saving ? : t('config_save')}
+
+ >
+ }
+ >
+ {addMode && (
+
+
+
+ )}
+
+
+ {
+ if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
+ }}
+ onBlur={() => {
+ if (!keyDirty) setApiKey(effective?.api_key_masked || '')
+ }}
+ onChange={(e) => {
+ setApiKey(e.target.value)
+ setKeyDirty(true)
+ }}
+ />
+
+
+
+ {hasBase && (
+
+ setApiBase(e.target.value)}
+ placeholder={effective?.api_base_placeholder || 'https://...'}
+ />
+
+ )}
+
+ )
+}
+
+const CustomProviderModal: React.FC<{
+ target: ModelProvider | 'new' | null
+ onClose: () => void
+ onSaved: () => Promise
+}> = ({ 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 (
+
+ {editing && (
+
+ {t('models_delete')}
+
+ )}
+
+ {t('config_cancel')}
+
+
+ {saving ? : t('config_save')}
+
+ >
+ }
+ >
+
+ setName(e.target.value)} placeholder="My Provider" />
+
+
+ setApiBase(e.target.value)}
+ placeholder="https://...../v1"
+ />
+
+
+ {
+ if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
+ }}
+ onChange={(e) => {
+ setApiKey(e.target.value)
+ setKeyDirty(true)
+ }}
+ />
+
+
+ )
+}
+
+// ============================================================
+// 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 (
+
+ {t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''}
+
+ )
+}
+
+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 (
+ } title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}>
+
+ {/* Reply mode — saved immediately */}
+
+ ({ 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 && {modeStatus}}
+
+
+ {mode !== 'off' && (
+ <>
+
+
+
+
+
+
+ {voiceOptions.length > 0 && (
+
+
+
+ )}
+
+
+ {status}
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+const EmbeddingCard: React.FC<{
+ state: CapabilityState
+ data: ModelsData
+ busy: boolean
+ status?: string
+ onSave: (provider: string, model: string) => void
+}> = ({ state, data, busy, status, onSave }) => (
+
+ {state.current_dim != null && (
+
+ {t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')}
+
+ )}
+
+)
+
+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(state.strategy || 'auto')
+ const [provider, setProvider] = useState(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 (
+ } title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}>
+
+
+
+
+ {strategy === 'fixed' && (
+
+
+
+ )}
+
+
+
+
+ {status}
+
+
+
+
+
+
+ setBochaOpen(false)}
+ onSave={(k) => {
+ onSaveBochaKey(k)
+ setBochaOpen(false)
+ }}
+ />
+
+ )
+}
+
+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 (
+
+
+ {t('config_cancel')}
+
+ onSave(dirty && !MASK_RE.test(key) ? key : '')}>
+ {busy ? : t('config_save')}
+
+ >
+ }
+ >
+
+ {
+ if (!dirty && MASK_RE.test(key)) setKey('')
+ }}
+ onChange={(e) => {
+ setKey(e.target.value)
+ setDirty(true)
+ }}
+ />
+
+
+ )
+}
+
+export default ModelsTab
diff --git a/desktop/src/renderer/src/pages/settings/modelsHelpers.ts b/desktop/src/renderer/src/pages/settings/modelsHelpers.ts
new file mode 100644
index 00000000..24ac31cc
--- /dev/null
+++ b/desktop/src/renderer/src/pages/settings/modelsHelpers.ts
@@ -0,0 +1,57 @@
+import type { ModelEntry, ModelOption, ModelProvider, ModelsData } from '../../types'
+import { localizedLabel } from '../../i18n'
+
+// Normalize a string|{value,hint} entry into a uniform option shape.
+export function normEntry(e: ModelEntry): ModelOption {
+ return typeof e === 'string' ? { value: e } : e
+}
+
+export function normEntries(arr?: ModelEntry[]): ModelOption[] {
+ return (arr || []).map(normEntry)
+}
+
+// Resolve a human label for a provider id, falling back to the id itself.
+// Handles expanded custom ids ("custom:") via the providers overview.
+export function providerLabel(data: ModelsData | null, id: string): string {
+ if (!id) return ''
+ const p = data?.providers?.find((x) => x.id === id)
+ if (p) return localizedLabel(p.label) || id
+ return id
+}
+
+export function findProvider(data: ModelsData | null, id: string): ModelProvider | undefined {
+ return data?.providers?.find((x) => x.id === id)
+}
+
+// Resolve the model list for a capability+provider, mirroring the web console:
+// 1. capability-scoped provider_models[id] (vision/image/asr/tts/embedding)
+// 2. provider_models['custom'] for expanded custom: providers
+// 3. fall back to the vendor's generic models[] (chat has no provider_models)
+export function resolveModels(
+ data: ModelsData | null,
+ providerId: string,
+ providerModels?: Record
+): ModelOption[] {
+ if (!providerId) return []
+ if (providerModels?.[providerId]) return normEntries(providerModels[providerId])
+ if (providerId.startsWith('custom:') && providerModels?.['custom']) {
+ return normEntries(providerModels['custom'])
+ }
+ return normEntries(findProvider(data, providerId)?.models)
+}
+
+// Voices for a tts provider may be a flat list or, for linkai, keyed by model.
+export function resolveVoices(
+ provider: string,
+ model: string,
+ voicesMap?: Record>
+): ModelOption[] {
+ const raw = voicesMap?.[provider]
+ if (!raw) return []
+ if (Array.isArray(raw)) return normEntries(raw)
+ // keyed by model (linkai)
+ const byModel = raw as Record
+ return normEntries(byModel[model] || [])
+}
+
+export const CUSTOM_OPTION = '__custom__'
diff --git a/desktop/src/renderer/src/pages/settings/primitives.tsx b/desktop/src/renderer/src/pages/settings/primitives.tsx
new file mode 100644
index 00000000..cfd9b764
--- /dev/null
+++ b/desktop/src/renderer/src/pages/settings/primitives.tsx
@@ -0,0 +1,196 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { ChevronDown } from 'lucide-react'
+import { t } from '../../i18n'
+
+// Shared presentational building blocks for the settings tabs.
+
+export const Card: React.FC<{ icon: React.ReactNode; title: string; subtitle?: string; children: React.ReactNode }> = ({
+ icon,
+ title,
+ subtitle,
+ children,
+}) => (
+
+
+
{icon}
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+ {children}
+
+)
+
+export const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({
+ label,
+ hint,
+ children,
+}) => (
+
+
+ {children}
+ {hint &&
{hint}
}
+
+)
+
+export interface DropdownOption {
+ value: string
+ label: string
+ hint?: string
+}
+
+export const Dropdown: React.FC<{
+ value: string
+ display?: string
+ placeholder?: string
+ options: DropdownOption[]
+ disabled?: boolean
+ onChange: (val: string) => void
+}> = ({ value, display, placeholder, options, disabled, onChange }) => {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ useEffect(() => {
+ const h = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', h)
+ return () => document.removeEventListener('mousedown', h)
+ }, [])
+ const current = display ?? options.find((o) => o.value === value)?.label ?? ''
+ return (
+
+
+ {open && (
+
+ {options.length === 0 && (
+
{t('models_no_options')}
+ )}
+ {options.map((o) => (
+
{
+ onChange(o.value)
+ setOpen(false)
+ }}
+ className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
+ o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
+ }`}
+ >
+
{o.label}
+ {o.hint &&
{o.hint}
}
+
+ ))}
+
+ )}
+
+ )
+}
+
+export const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
+
+)
+
+export const TextInput: React.FC> = (props) => (
+
+)
+
+export const SaveRow: React.FC<{ status: string; onSave: () => void; label?: string }> = ({
+ status,
+ onSave,
+ label,
+}) => (
+
+ {status}
+
+
+)
+
+export const MASK_RE = /[*•]/
+
+export const Modal: React.FC<{
+ open: boolean
+ title: string
+ onClose: () => void
+ children: React.ReactNode
+ footer?: React.ReactNode
+}> = ({ open, title, onClose, children, footer }) => {
+ if (!open) return null
+ return (
+ {
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
+
+
{title}
+
+
+
{children}
+ {footer &&
{footer}
}
+
+
+ )
+}
+
+export const Btn: React.FC<
+ React.ButtonHTMLAttributes & { variant?: 'primary' | 'ghost' | 'danger' }
+> = ({ variant = 'ghost', className, children, ...props }) => {
+ const styles =
+ variant === 'primary'
+ ? 'bg-accent text-accent-contrast hover:bg-accent-hover'
+ : variant === 'danger'
+ ? 'bg-danger-soft text-danger hover:bg-danger/15 border border-danger-border'
+ : 'border border-strong text-content-secondary hover:bg-surface-2'
+ return (
+
+ )
+}
diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts
index 6966f2ca..0b85e072 100644
--- a/desktop/src/renderer/src/types.ts
+++ b/desktop/src/renderer/src/types.ts
@@ -175,12 +175,16 @@ export interface HistoryPage {
// Config
// ============================================================
+/** A label that may be localized (some providers/channels return {zh,en}). */
+export type LocalizedLabel = string | { zh: string; en: string }
+
export interface ProviderMeta {
- label: string
+ label: LocalizedLabel
models: string[]
- api_base_key?: string
- api_base_default?: string
- api_key_field?: string
+ api_base_key?: string | null
+ api_base_default?: string | null
+ api_base_placeholder?: string
+ api_key_field?: string | null
[k: string]: unknown
}
@@ -206,34 +210,95 @@ export interface ConfigData {
// Models console (/api/models)
// ============================================================
+// A model/voice entry can be a bare id or an annotated {value, hint} object.
+export interface ModelOption {
+ value: string
+ hint?: string
+}
+export type ModelEntry = string | ModelOption
+
export interface ModelProvider {
id: string
- label: string
+ label: LocalizedLabel
configured: boolean
is_custom: boolean
custom_id?: string
+ custom_name?: string
active?: boolean
+ api_key_field?: string | null
+ api_base_field?: string | null
api_key_masked?: string
api_base?: string
- models: string[]
+ api_base_default?: string
+ api_base_placeholder?: string
+ models: ModelEntry[]
}
export type CapabilityKey = 'chat' | 'vision' | 'asr' | 'tts' | 'embedding' | 'image' | 'search'
+// Search providers are described as objects (unlike other capabilities which
+// list provider ids only).
+export interface SearchProviderMeta {
+ id: string
+ label: LocalizedLabel
+ configured: boolean
+ needs_dedicated_key: boolean
+ api_key_masked?: string
+}
+
export interface CapabilityState {
+ editable?: boolean
current_provider?: string
current_model?: string
+ current_voice?: string
+ current_dim?: number | null
+ suggested_provider?: string
providers?: string[]
- provider_models?: Record
+ // provider_models entries are string | {value,hint}
+ provider_models?: Record
+ // tts only: voices keyed by provider; linkai keyed further by model id
+ provider_voices?: Record>
+ // vision/image
strategy?: string
+ user_specified_model?: string
fallback_provider?: string
fallback_model?: string
+ // tts
+ reply_mode?: 'off' | 'voice_if_voice' | 'always'
+ use_linkai?: boolean
+ // image
+ runtime_active?: boolean
+ note?: string
+ // search
+ fixed_provider?: string
+ configured_providers?: string[]
+ available?: boolean
[k: string]: unknown
}
+export interface SearchCapabilityState {
+ editable?: boolean
+ providers: SearchProviderMeta[]
+ strategy?: 'auto' | 'fixed' | string
+ current_provider?: string
+ fixed_provider?: string
+ configured_providers?: string[]
+ available?: boolean
+}
+
export interface ModelsData {
+ status?: string
providers: ModelProvider[]
- capabilities: Record
+ capabilities: {
+ chat: CapabilityState
+ vision: CapabilityState
+ asr: CapabilityState
+ tts: CapabilityState
+ embedding: CapabilityState
+ image: CapabilityState
+ // search has a richer providers[] shape
+ search: SearchCapabilityState
+ }
}
export type ModelsAction =
@@ -242,7 +307,7 @@ export type ModelsAction =
| { action: 'set_custom_provider'; name: string; id?: string; api_base: string; api_key?: string; model?: string; make_active?: boolean }
| { action: 'delete_custom_provider'; id: string }
| { action: 'set_active_custom_provider'; id: string }
- | { action: 'set_capability'; capability: CapabilityKey; provider_id: string; model: string; voice?: string; strategy?: string; provider?: string }
+ | { action: 'set_capability'; capability: CapabilityKey; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string }
| { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' }
| { action: 'set_search_credential'; api_key: string }