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]
|
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
|
||||||
value: id,
|
.filter((id) => isConfigured(id) || id === provider)
|
||||||
label: localizedLabel(providerMeta(id)?.label) || id,
|
.map((id) => ({
|
||||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
value: id,
|
||||||
}))
|
label: localizedLabel(providerMeta(id)?.label) || id,
|
||||||
|
}))
|
||||||
const currentMeta = providerMeta(provider)
|
const currentMeta = providerMeta(provider)
|
||||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||||
const modelOptions = [
|
const modelOptions = [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const h = (e: MouseEvent) => {
|
if (!open) return
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
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)
|
// Keep the fixed menu anchored to the trigger on scroll/resize by
|
||||||
return () => document.removeEventListener('mousedown', h)
|
// 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,28 +103,35 @@ 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 &&
|
||||||
{options.length === 0 && (
|
createPortal(
|
||||||
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
<div
|
||||||
)}
|
ref={menuRef}
|
||||||
{options.map((o) => (
|
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
|
||||||
<div
|
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
|
||||||
key={o.value}
|
>
|
||||||
onClick={() => {
|
{options.length === 0 && (
|
||||||
onChange(o.value)
|
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
||||||
setOpen(false)
|
)}
|
||||||
}}
|
{options.map((o) => (
|
||||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
<div
|
||||||
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
key={o.value}
|
||||||
}`}
|
onClick={() => {
|
||||||
>
|
onChange(o.value)
|
||||||
<div className="truncate">{o.label}</div>
|
setOpen(false)
|
||||||
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
|
}}
|
||||||
</div>
|
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>
|
}`}
|
||||||
)}
|
>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user