desktop: fix model config dropdowns and provider listing

This commit is contained in:
zhayujie
2026-07-01 16:28:40 +08:00
parent 80fea77c86
commit b8dad38622
4 changed files with 90 additions and 44 deletions

View File

@@ -170,12 +170,16 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
return !!config?.api_keys?.[f]
}
// Only list configured providers (built-in or custom). Unconfigured vendors
// have no usable credentials, so showing them — flagged "unconfigured" — is
// just noise. Keep the current selection so a saved value never disappears.
const providerIds = config?.providers ? Object.keys(config.providers) : []
const providerOptions = providerIds.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const providerOptions = providerIds
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
}))
const currentMeta = providerMeta(provider)
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions = [

View File

@@ -46,24 +46,26 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
// A provider is configured when it has credentials (custom providers always
// carry their own). Unconfigured ones stay selectable but are flagged so the
// user is guided to set up the API key.
// A provider is configured when it has credentials (a custom provider counts
// only once it actually carries a name/key, not as an empty placeholder).
const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id)
if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name)
}
// Only surface providers that are actually configured (built-in or custom).
// An unconfigured vendor has no usable credentials, so listing it — and
// flagging it "unconfigured" — only adds noise. The currently-selected
// provider is always kept so a saved value never silently disappears.
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state.providers || []).map((id) => ({
value: id,
label: providerLabel(data, id),
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const opts = (state.providers || [])
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({ value: id, label: providerLabel(data, id) }))
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return opts
}, [state.providers, data, allowAuto, autoLabel])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.providers, data, allowAuto, autoLabel, provider])
const currentUnconfigured = !!provider && !isConfigured(provider)

View File

@@ -294,8 +294,11 @@ const VendorModal: React.FC<{
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])
// 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('')

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { t } from '../../i18n'
@@ -50,13 +51,42 @@ export const Dropdown: React.FC<{
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// The menu is rendered in a portal with fixed positioning so it's never
// clipped by an ancestor's `overflow` (e.g. a modal's scroll container).
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null)
const place = () => {
const el = ref.current
if (!el) return
const r = el.getBoundingClientRect()
setRect({ left: r.left, top: r.bottom + 4, width: r.width })
}
useLayoutEffect(() => {
if (open) place()
}, [open])
useEffect(() => {
const h = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
if (!open) return
const onDown = (e: MouseEvent) => {
const target = e.target as Node
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
// Keep the fixed menu anchored to the trigger on scroll/resize by
// re-measuring — NOT closing. Closing on scroll makes the dropdown vanish
// the moment the user scrolls the settings page.
document.addEventListener('mousedown', onDown)
window.addEventListener('resize', place, true)
window.addEventListener('scroll', place, true)
return () => {
document.removeEventListener('mousedown', onDown)
window.removeEventListener('resize', place, true)
window.removeEventListener('scroll', place, true)
}
}, [open])
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
return (
<div ref={ref} className="relative">
@@ -73,28 +103,35 @@ export const Dropdown: React.FC<{
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
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'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>
)}
{open &&
rect &&
createPortal(
<div
ref={menuRef}
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
>
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
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'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>,
document.body
)}
</div>
)
}