From 1af1fb6913ddd1a42b772e385a6ce94803524df6 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 4 Mar 2026 21:29:52 +0800 Subject: [PATCH] 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. --- .../model-provider-page/index.spec.tsx | 4 + .../model-provider-page/index.tsx | 21 ++++ .../provider-added-card/index.tsx | 43 ++++--- .../provider-card-actions.tsx | 111 ++++++++++++++++++ .../provider-icon/index.tsx | 4 +- .../model-provider-page/utils.ts | 5 + 6 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx diff --git a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx index ede9c1f7fe..27cead7eb2 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx @@ -83,6 +83,10 @@ vi.mock('./system-model-selector', () => ({ default: () =>
, })) +vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: () => ({ data: undefined }), +})) + describe('ModelProviderPage', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 6e4c7663b4..9a0a046fcc 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -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() + 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) => { ))}
@@ -163,6 +183,7 @@ const ModelProviderPage = ({ searchText }: Props) => { notConfigured key={provider.provider} provider={provider} + pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))} /> ))} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index 3243e5ac86..8361f6068d 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -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 = ({ 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 = ({ >
- +
+ + {pluginDetail && ( + + )} +
- { - provider.supported_model_types.map(modelType => ( - - {modelTypeFormat(modelType)} - - )) - } + {provider.supported_model_types.map(modelType => ( + + {modelTypeFormat(modelType)} + + ))}
- { - showCredential && ( - - ) - } + {showCredential && ( + + )}
{ collapsed && ( diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx new file mode 100644 index 0000000000..a732a76a1c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx @@ -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 = ({ 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 && ( + + {version} + {isFromMarketplace && } + {hasNewVersion && ( + + )} + + )} + /> + )} + + handleUpdate()} + onRemove={modalStates.showDeleteConfirm} + detailUrl={detailUrl} + /> + + + + ) +} + +export default ProviderCardActions diff --git a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx index 199e6c931c..17382568d1 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx @@ -21,7 +21,7 @@ const ProviderIcon: FC = ({ if (provider.provider === 'langgenius/anthropic/anthropic') { return ( -
+
{theme === Theme.dark && } {theme === Theme.light && }
@@ -30,7 +30,7 @@ const ProviderIcon: FC = ({ if (provider.provider === 'langgenius/openai/openai') { return ( -
+
) diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index 21e32ad178..7b8928ec63 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -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 = {