dify/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
yyh 0923ebaf88
chore(agent-v2): sync daily changes (#38162)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <wylswz@163.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Yanli 盐粒 <yanli@dify.ai>
Co-authored-by: 盐粒 Yanli <beautyyuyanli@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
2026-07-01 05:07:23 +00:00

343 lines
14 KiB
TypeScript

import type { DefaultModel, Model } from '../declarations'
import type { ModelSelectorPreviewPayload } from './popup-item'
import type { ModelSelectorModelPredicate } from './types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent } from '@langgenius/dify-ui/preview-card'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_MODAL_ACTION, ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { IS_CLOUD_EDITION } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { CustomConfigurationStatusEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelTypeFormat, providerKeyToPluginId, sizeFormat } from '../utils'
import FeatureIcon from './feature-icon'
import MarketplaceSection from './marketplace-section'
import { createModelSelectorSearchIndex, filterModelSelectorModels } from './model-search'
import ModelSelectorEmptyState from './popup-empty-state'
import PopupItem from './popup-item'
import { CompatibleModelsNotice, ModelProviderSettingsFooter, ModelSelectorPopupFrame, ModelSelectorScrollBody, ModelSelectorSearchHeader, ShowIncompatibleModelsButton } from './popup-layout'
export type PopupProps = {
defaultModel?: DefaultModel
inputValue: string
modelList: Model[]
scopeFeatures?: ModelFeatureEnum[]
hideProviderSettingsFooter?: boolean
providerSettingsSource?: 'agent'
modelPredicate?: ModelSelectorModelPredicate
onConfigureEmptyState?: () => void
onInputValueChange: (value: string) => void
onOpenMarketplace?: () => void
onHide: () => void
}
function Popup({
defaultModel,
inputValue,
modelList,
scopeFeatures = [],
hideProviderSettingsFooter,
providerSettingsSource,
modelPredicate,
onConfigureEmptyState,
onInputValueChange,
onOpenMarketplace,
onHide,
}: PopupProps) {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ModelSelectorPreviewPayload>(), [])
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const openIntegrationsSetting = useIntegrationsSetting()
const { modelProviders } = useProviderContext()
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: systemFeatures => systemFeatures.enable_marketplace,
})
const {
plugins: allPlugins,
isLoading: isMarketplacePluginsLoading,
} = useMarketplaceAllPlugins(modelProviders, '', enableMarketplace)
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
const { refreshPluginList } = useRefreshPluginList()
const { canInstallPlugin } = useWorkspacePluginInstallPermission()
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
const { isExhausted: isCreditsExhausted } = useTrialCredits()
const { data: trialModels = [] } = useQuery(consoleQuery.trialModels.get.queryOptions({
enabled: IS_CLOUD_EDITION,
select: data => data.trial_models,
}))
const installedProviderMap = useMemo(() => new Map(
modelProviders.map(provider => [provider.provider, provider]),
), [modelProviders])
const aiCreditVisibleProviders = useMemo(() => {
if (!enableMarketplace || isCreditsExhausted)
return new Set<string>()
return new Set(
modelProviders
.filter(provider => providerSupportsCredits(provider, trialModels))
.map(provider => provider.provider),
)
}, [enableMarketplace, isCreditsExhausted, modelProviders, trialModels])
const showCreditsExhaustedAlert = enableMarketplace
&& isCreditsExhausted
&& modelProviders.some(provider => providerSupportsCredits(provider, trialModels))
const hasApiKeyFallback = modelProviders.some((provider) => {
const isApiKeyActive = provider.custom_configuration?.status === CustomConfigurationStatusEnum.active
return isApiKeyActive && providerSupportsCredits(provider, trialModels)
})
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
if (!enableMarketplace || !canInstallPlugin || !allPlugins || isMarketplacePluginsLoading || installingProvider)
return
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
if (!plugin)
return
const uniqueIdentifier = plugin.latest_package_identifier
setInstallingProvider(key)
try {
const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier)
if (!all_installed) {
const { check } = checkTaskStatus()
await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier })
}
refreshPluginList(plugin)
}
catch { }
finally {
setInstallingProvider(null)
}
}, [allPlugins, enableMarketplace, canInstallPlugin, installPackageFromMarketPlace, installingProvider, isMarketplacePluginsLoading, refreshPluginList])
const installedModelList = useMemo(() => {
const modelMap = new Map(modelList.map(model => [model.provider, model]))
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
const installedProvider = installedProviderMap.get(providerKey)
if (!installedProvider)
return []
const matchedModel = modelMap.get(providerKey)
if (matchedModel)
return [matchedModel]
if (!aiCreditVisibleProviders.has(providerKey))
return []
return [{
provider: installedProvider.provider,
icon_small: installedProvider.icon_small,
icon_small_dark: installedProvider.icon_small_dark,
label: installedProvider.label,
models: [],
status: ModelStatusEnum.active,
}]
})
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
return [...installedMarketplaceModels, ...otherModels]
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const searchIndex = useMemo(
() => createModelSelectorSearchIndex(installedModelList, language),
[installedModelList, language],
)
const filteredModelList = useMemo(() => filterModelSelectorModels({
aiCreditVisibleProviders,
defaultModel,
inputValue,
installedModelList,
modelPredicate: showIncompatibleModels ? undefined : modelPredicate,
scopeFeatures,
searchIndex,
}), [aiCreditVisibleProviders, defaultModel, inputValue, installedModelList, modelPredicate, scopeFeatures, searchIndex, showIncompatibleModels])
const shouldShowModelPredicateReveal = !!modelPredicate
const marketplaceProviders = useMemo(() => {
if (!enableMarketplace)
return []
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [enableMarketplace, modelProviders])
const handleOpenSettings = useCallback(() => {
onHide()
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER, source: providerSettingsSource })
}, [onHide, openIntegrationsSetting, providerSettingsSource])
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
const isProviderSettingsCurrentPage = searchParams?.get('action') === ACCOUNT_SETTING_MODAL_ACTION
&& searchParams?.get('tab') === ACCOUNT_SETTING_TAB.PROVIDER
const handleConfigureEmptyState = onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings)
return (
<ModelSelectorPopupFrame>
<ModelSelectorSearchHeader
inputValue={inputValue}
onInputValueChange={onInputValueChange}
/>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
<ComboboxList className="max-h-none overflow-visible p-0">
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
previewCardHandle={previewCardHandle}
onPreviewCardClose={handleClosePreviewCard}
onHide={onHide}
/>
))
}
</div>
</ComboboxList>
<div className="pb-1">
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleConfigureEmptyState}
/>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary">
{t('modelProvider.selector.noModelFoundForSearch', { ns: 'common', query: inputValue })}
</div>
)}
{scopeFeatures.length > 0 && (
<CompatibleModelsNotice />
)}
{shouldShowModelPredicateReveal && (
<ShowIncompatibleModelsButton
showIncompatibleModels={showIncompatibleModels}
onClick={() => setShowIncompatibleModels(value => !value)}
/>
)}
{enableMarketplace && (
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
canInstallPlugin={canInstallPlugin}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
/>
)}
</div>
</ModelSelectorScrollBody>
<PreviewCard handle={previewCardHandle}>
{({ payload }) => (
<ModelSelectorPreviewCard
capabilitiesLabel={t('model.capabilities', { ns: 'common' })}
language={language}
payload={payload as ModelSelectorPreviewPayload | undefined}
/>
)}
</PreviewCard>
{!hideProviderSettingsFooter && !isProviderSettingsCurrentPage && (
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
)}
</ModelSelectorPopupFrame>
)
}
type ModelSelectorPreviewCardProps = {
capabilitiesLabel: string
language: string
payload?: ModelSelectorPreviewPayload
}
function ModelSelectorPreviewCard({
capabilitiesLabel,
language,
payload,
}: ModelSelectorPreviewCardProps) {
if (!payload)
return null
const { provider, modelItem } = payload
return (
<PreviewCardContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className="size-5 shrink-0"
provider={provider}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{capabilitiesLabel}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)}
</div>
</PreviewCardContent>
)
}
export default Popup