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,11 +170,15 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
return !!config?.api_keys?.[f] 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 providerIds = config?.providers ? Object.keys(config.providers) : []
const providerOptions = providerIds.map((id) => ({ const providerOptions = providerIds
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({
value: id, value: id,
label: localizedLabel(providerMeta(id)?.label) || id, label: localizedLabel(providerMeta(id)?.label) || id,
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
})) }))
const currentMeta = providerMeta(provider) const currentMeta = providerMeta(provider)
const currentUnconfigured = !!provider && !isConfigured(provider) const currentUnconfigured = !!provider && !isConfigured(provider)

View File

@@ -46,24 +46,26 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
const [customModel, setCustomModel] = useState('') const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false) const [showCustom, setShowCustom] = useState(false)
// A provider is configured when it has credentials (custom providers always // A provider is configured when it has credentials (a custom provider counts
// carry their own). Unconfigured ones stay selectable but are flagged so the // only once it actually carries a name/key, not as an empty placeholder).
// user is guided to set up the API key.
const isConfigured = (id: string): boolean => { const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id) const p = data?.providers?.find((x) => x.id === id)
if (!p) return true if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name) 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 providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state.providers || []).map((id) => ({ const opts = (state.providers || [])
value: id, .filter((id) => isConfigured(id) || id === provider)
label: providerLabel(data, id), .map((id) => ({ value: id, label: providerLabel(data, id) }))
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts] if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return 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) const currentUnconfigured = !!provider && !isConfigured(provider)

View File

@@ -294,8 +294,11 @@ const VendorModal: React.FC<{
const open = !!provider || addMode const open = !!provider || addMode
// In add-mode the user first picks a built-in provider; that selection // In add-mode the user first picks a built-in provider; that selection
// becomes the effective provider whose key/base fields we edit. // becomes the effective provider whose key/base fields we edit. Exclude ALL
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers]) // 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 firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
const [pickId, setPickId] = useState('') 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 { ChevronDown } from 'lucide-react'
import { t } from '../../i18n' import { t } from '../../i18n'
@@ -50,13 +51,42 @@ export const Dropdown: React.FC<{
}> = ({ value, display, placeholder, options, disabled, onChange }) => { }> = ({ value, display, placeholder, options, disabled, onChange }) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => { const menuRef = useRef<HTMLDivElement>(null)
const h = (e: MouseEvent) => { // The menu is rendered in a portal with fixed positioning so it's never
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) // 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 })
} }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h) useLayoutEffect(() => {
}, []) if (open) place()
}, [open])
useEffect(() => {
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)
}
// 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 ?? '' const current = display ?? options.find((o) => o.value === value)?.label ?? ''
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
@@ -73,8 +103,14 @@ export const Dropdown: React.FC<{
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span> <span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} /> <ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button> </button>
{open && ( {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"> 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 && ( {options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div> <div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)} )}
@@ -93,7 +129,8 @@ export const Dropdown: React.FC<{
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>} {o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div> </div>
))} ))}
</div> </div>,
document.body
)} )}
</div> </div>
) )