refactor(web): enhance model selector functionality and improve UI consistency

- Removed unnecessary ESLint suppressions for better code quality.
- Updated the ModelParameterModal and ModelSelector components to ensure consistent class ordering.
- Added onHide prop to ModelSelector for better control over dropdown visibility.
- Introduced useChangeProviderPriority hook to manage provider priority changes more effectively.
- Integrated CreditsExhaustedAlert in the Popup component to handle API key status more gracefully.
This commit is contained in:
CodingOnStar
2026-03-09 12:24:54 +08:00
parent b89ee4807f
commit e40b31b9c4
8 changed files with 160 additions and 103 deletions

View File

@ -174,13 +174,14 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
<div className="relative">
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
<div className={cn('mb-1 flex h-6 items-center text-text-secondary system-sm-semibold')}>
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
onHide={() => setOpen(false)}
/>
</div>
{
@ -196,7 +197,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
{
!isLoading && !!parameterRules.length && (
<div className="mb-2 flex items-center justify-between">
<div className={cn('system-sm-semibold flex h-6 items-center text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
<div className={cn('flex h-6 items-center text-text-secondary system-sm-semibold')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
@ -225,7 +226,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
</div>
{!hideDebugWithMultipleModel && (
<div
className="bg-components-section-burn system-sm-regular flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent"
className="bg-components-section-burn flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent system-sm-regular"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{

View File

@ -24,6 +24,7 @@ type ModelSelectorProps = {
triggerClassName?: string
popupClassName?: string
onSelect?: (model: DefaultModel) => void
onHide?: () => void
readonly?: boolean
scopeFeatures?: ModelFeatureEnum[]
deprecatedClassName?: string
@ -35,6 +36,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
triggerClassName,
popupClassName,
onSelect,
onHide,
readonly,
scopeFeatures = [],
deprecatedClassName,
@ -113,7 +115,10 @@ const ModelSelector: FC<ModelSelectorProps> = ({
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => setOpen(false)}
onHide={() => {
setOpen(false)
onHide?.()
}}
triggerRef={triggerRef}
/>
</PortalToFollowElemContent>

View File

@ -104,7 +104,7 @@ describe('PopupItem', () => {
it('should call onSelect when clicking an active model', () => {
const onSelect = vi.fn()
render(<PopupItem model={makeModel()} onSelect={onSelect} />)
render(<PopupItem model={makeModel()} onSelect={onSelect} onHide={vi.fn()} />)
fireEvent.click(screen.getByText('GPT-4'))
@ -117,6 +117,7 @@ describe('PopupItem', () => {
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
onSelect={onSelect}
onHide={vi.fn()}
/>,
)
@ -130,6 +131,7 @@ describe('PopupItem', () => {
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -149,6 +151,7 @@ describe('PopupItem', () => {
models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -167,6 +170,7 @@ describe('PopupItem', () => {
defaultModel={defaultModel}
model={makeModel()}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -174,7 +178,7 @@ describe('PopupItem', () => {
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
@ -188,7 +192,7 @@ describe('PopupItem', () => {
})
it('should show credential name when using custom provider', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
@ -203,7 +207,7 @@ describe('PopupItem', () => {
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
@ -215,7 +219,7 @@ describe('PopupItem', () => {
})],
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
})
@ -230,7 +234,7 @@ describe('PopupItem', () => {
currentWorkspace: { trial_credits: 100, trial_credits_used: 100 },
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})

View File

@ -4,10 +4,14 @@ import type {
Model,
ModelItem,
} from '../declarations'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -28,6 +32,9 @@ import {
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import {
modelTypeFormat,
sizeFormat,
@ -38,13 +45,16 @@ type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
onSelect: (provider: string, model: ModelItem) => void
onHide: () => void
}
const PopupItem: FC<PopupItemProps> = ({
defaultModel,
model,
onSelect,
onHide,
}) => {
const [collapsed, setCollapsed] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation()
const language = useLanguage()
const { currentWorkspace } = useAppContext()
@ -82,6 +92,14 @@ const PopupItem: FC<PopupItemProps> = ({
const isApiKeyActive = currentProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active
const credentialName = currentProvider?.custom_configuration.current_credential_name
const state = useCredentialPanelState(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const handleCloseDropdown = useCallback(() => {
setDropdownOpen(false)
onHide()
}, [onHide])
return (
<div className="mb-1">
<div className="flex h-[22px] items-center justify-between px-3 text-xs font-medium text-text-tertiary">
@ -92,39 +110,53 @@ const PopupItem: FC<PopupItemProps> = ({
{model.label[language] || model.label.en_US}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', collapsed && '-rotate-90')} />
</div>
<div className="flex items-center text-text-tertiary system-xs-medium">
{isUsingCredits
? (
hasCredits
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger
render={(
<div className="flex cursor-pointer items-center text-text-tertiary system-xs-medium">
{isUsingCredits
? (
<>
<span className="i-ri-globe-line h-3 w-3" />
<span className="ml-1">{t('modelProvider.selector.aiCredits', { ns: 'common' })}</span>
</>
hasCredits
? (
<>
<span className="i-custom-vender-line-financeandecommerce-credits-coin h-3 w-3" />
<span className="ml-1">{t('modelProvider.selector.aiCredits', { ns: 'common' })}</span>
</>
)
: (
<>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning-secondary" />
<span className="ml-1 text-text-warning">{t('modelProvider.selector.creditsExhausted', { ns: 'common' })}</span>
</>
)
)
: (
<>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning-secondary" />
<span className="ml-1 text-text-warning">{t('modelProvider.selector.creditsExhausted', { ns: 'common' })}</span>
</>
)
)
: credentialName
? (
<>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-[2px] border', isApiKeyActive ? 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg' : 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg')} />
<span className="ml-1 text-text-tertiary">{credentialName}</span>
</>
)
: (
<>
<span className="h-1.5 w-1.5 shrink-0 rounded-[2px] border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg" />
<span className="ml-1 text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}
<span className={cn('i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary', collapsed && '-rotate-90')} />
</div>
: credentialName
? (
<>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-[2px] border', isApiKeyActive ? 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg' : 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg')} />
<span className="ml-1 text-text-tertiary">{credentialName}</span>
</>
)
: (
<>
<span className="h-1.5 w-1.5 shrink-0 rounded-[2px] border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg" />
<span className="ml-1 text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}
<span className={cn('i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary', collapsed && '-rotate-90')} />
</div>
)}
/>
<PopoverContent placement="bottom-end" className="z-[1003]">
<DropdownContent
provider={currentProvider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
onClose={handleCloseDropdown}
/>
</PopoverContent>
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
<Tooltip

View File

@ -19,8 +19,9 @@ import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import { ModelFeatureEnum } from '../declarations'
import { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import PopupItem from './popup-item'
@ -56,6 +57,9 @@ const Popup: FC<PopupProps> = ({
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
const { refreshPluginList } = useRefreshPluginList()
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
const hasApiKeyFallback = useMemo(() => {
return modelProviders.some(p => p.custom_configuration?.status === CustomConfigurationStatusEnum.active)
}, [modelProviders])
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
if (!allPlugins || installingProvider)
@ -101,7 +105,7 @@ const Popup: FC<PopupProps> = ({
}, [])
const filteredModelList = useMemo(() => {
return modelList.map((model) => {
const filtered = modelList.map((model) => {
const filteredModels = model.models
.filter((modelItem) => {
if (modelItem.label[language] !== undefined)
@ -121,7 +125,17 @@ const Popup: FC<PopupProps> = ({
})
return { ...model, models: filteredModels }
}).filter(model => model.models.length > 0)
}, [language, modelList, scopeFeatures, searchText])
if (defaultModel?.provider) {
const selectedIndex = filtered.findIndex(m => m.provider === defaultModel.provider)
if (selectedIndex > 0) {
const [selected] = filtered.splice(selectedIndex, 1)
filtered.unshift(selected)
}
}
return filtered
}, [defaultModel?.provider, language, modelList, scopeFeatures, searchText])
const marketplaceProviders = useMemo(() => {
const installedProviders = new Set(modelList.map(m => m.provider))
@ -158,6 +172,7 @@ const Popup: FC<PopupProps> = ({
}
</div>
</div>
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
<div className="p-1">
{
filteredModelList.map(model => (
@ -166,6 +181,7 @@ const Popup: FC<PopupProps> = ({
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}

View File

@ -1,24 +1,12 @@
import type {
ModelProvider,
PreferredProviderTypeEnum,
} from '../declarations'
import type { ModelProvider } from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import { consoleQuery } from '@/service/client'
import {
ConfigurationMethodEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { useChangeProviderPriority } from './use-change-provider-priority'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
type CredentialPanelProps = {
@ -37,47 +25,8 @@ const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const state = useCredentialPanelState(provider)
const providerName = provider.provider
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: providerName,
},
},
})
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
},
onError: () => {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
}),
)
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
changePriority({
params: { provider: providerName },
body: { preferred_provider_type: key },
})
}
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(provider)
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)

View File

@ -0,0 +1,53 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { consoleQuery } from '@/service/client'
import { ConfigurationMethodEnum } from '../declarations'
import { useUpdateModelList, useUpdateModelProviders } from '../hooks'
export function useChangeProviderPriority(provider: ModelProvider) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const providerName = provider.provider
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: providerName,
},
},
})
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
},
onError: () => {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
}),
)
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
changePriority({
params: { provider: providerName },
body: { preferred_provider_type: key },
})
}
return { isChangingPriority, handleChangePriority }
}