feat(web): add version badge and actions menu to provider cards

Integrate plugin version management into model provider cards by
reusing existing plugin detail panel hooks and components. Batch
query installed plugins at list level to avoid N+1 requests.
This commit is contained in:
yyh 2026-03-04 21:29:52 +08:00
parent 1f0c36e9f7
commit 1af1fb6913
No known key found for this signature in database
6 changed files with 168 additions and 20 deletions

View File

@ -83,6 +83,10 @@ vi.mock('./system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: undefined }),
}))
describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@ -1,11 +1,13 @@
import type {
ModelProvider,
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useCheckInstalled } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import {
CustomConfigurationStatusEnum,
@ -18,6 +20,7 @@ import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
import QuotaPanel from './provider-added-card/quota-panel'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
@ -37,6 +40,22 @@ const ModelProviderPage = ({ searchText }: Props) => {
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers } = useProviderContext()
const { data: systemFeatures } = useSystemFeaturesQuery()
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
enabled: allPluginIds.length > 0,
})
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
if (installedPlugins?.plugins) {
for (const plugin of installedPlugins.plugins)
map.set(plugin.plugin_id, plugin)
}
return map
}, [installedPlugins])
const enableMarketplace = systemFeatures?.enable_marketplace ?? false
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
@ -150,6 +169,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
<ProviderAddedCard
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
@ -163,6 +183,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
notConfigured
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>

View File

@ -4,6 +4,7 @@ import type {
ModelProvider,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '../utils'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,6 +15,7 @@ import {
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { fetchModelProviderModelList } from '@/service/common'
import { cn } from '@/utils/classnames'
import { ConfigurationMethodEnum } from '../declarations'
@ -25,18 +27,22 @@ import {
} from '../utils'
import CredentialPanel from './credential-panel'
import ModelList from './model-list'
import ProviderCardActions from './provider-card-actions'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
notConfigured?: boolean
provider: ModelProvider
pluginDetail?: PluginDetail
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
notConfigured,
provider,
pluginDetail,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const { refreshModelProviders } = useProviderContext()
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
@ -87,27 +93,28 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
>
<div className="flex rounded-t-xl py-2 pl-3 pr-2">
<div className="grow px-1 pb-0.5 pt-1">
<ProviderIcon
className="mb-2"
provider={provider}
/>
<div className="mb-2 flex items-center gap-1">
<ProviderIcon provider={provider} />
{pluginDetail && (
<ProviderCardActions
detail={pluginDetail}
onUpdate={refreshModelProviders}
/>
)}
</div>
<div className="flex gap-0.5">
{
provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))
}
{provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))}
</div>
</div>
{
showCredential && (
<CredentialPanel
provider={provider}
/>
)
}
{showCredential && (
<CredentialPanel
provider={provider}
/>
)}
</div>
{
collapsed && (

View File

@ -0,0 +1,111 @@
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useMemo } from 'react'
import Badge from '@/app/components/base/badge'
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import { PluginSource } from '@/app/components/plugins/types'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
type Props = {
detail: PluginDetail
onUpdate?: () => void
}
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { theme } = useTheme()
const locale = useLocale()
const { source, version, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const {
modalStates,
versionPicker,
hasNewVersion,
isAutoUpgradeEnabled,
isFromMarketplace,
} = useDetailHeaderState(detail)
const {
handleUpdate,
handleUpdatedFromMarketplace,
handleDelete,
} = usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace,
onUpdate,
})
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
versionPicker.setTargetVersion(state)
handleUpdate(state.isDowngrade)
}
const detailUrl = useMemo(() => {
if (source === PluginSource.github)
return meta?.repo ? `https://github.com/${meta.repo}` : ''
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return ''
}, [source, meta?.repo, author, name, locale, theme])
return (
<>
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
trigger={(
<Badge
className={cn(
'relative',
versionPicker.isShow && 'bg-state-base-hover',
isFromMarketplace && 'hover:bg-state-base-hover',
)}
uppercase
>
<span>{version}</span>
{isFromMarketplace && <span className="i-ri-arrow-down-s-line h-3 w-3 text-text-tertiary" />}
{hasNewVersion && (
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-state-destructive-solid" />
)}
</Badge>
)}
/>
)}
<OperationDropdown
source={source}
onInfo={modalStates.showPluginInfo}
onCheckVersion={() => handleUpdate()}
onRemove={modalStates.showDeleteConfirm}
detailUrl={detailUrl}
/>
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={versionPicker.targetVersion}
isDowngrade={versionPicker.isDowngrade}
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
onDelete={handleDelete}
/>
</>
)
}
export default ProviderCardActions

View File

@ -21,7 +21,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className="mb-2 py-[7px]">
<div className={cn('py-[7px]', className)}>
{theme === Theme.dark && <AnthropicLight className="h-2.5 w-[90px]" />}
{theme === Theme.light && <AnthropicDark className="h-2.5 w-[90px]" />}
</div>
@ -30,7 +30,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/openai/openai') {
return (
<div className="mb-2">
<div className={className}>
<Openai className="h-6 w-auto text-text-inverted-dimmed" />
</div>
)

View File

@ -21,6 +21,11 @@ import {
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export const providerToPluginId = (providerKey: string): string => {
const lastSlash = providerKey.lastIndexOf('/')
return lastSlash > 0 ? providerKey.slice(0, lastSlash) : ''
}
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {