mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
desktop: fix model config dropdowns and provider listing
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user