mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
Add sticky positioning to provider title rows so they remain visible while scrolling through models. Remove top padding from list container to prevent the first provider title from shifting up before sticking.
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
import type { FC } from 'react'
|
|
import type {
|
|
DefaultModel,
|
|
Model,
|
|
ModelItem,
|
|
} from '../declarations'
|
|
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
|
import { useTheme } from 'next-themes'
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Button from '@/app/components/base/button'
|
|
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
|
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 { IS_CLOUD_EDITION } from '@/config'
|
|
import { useModalContext } from '@/context/modal-context'
|
|
import { useProviderContext } from '@/context/provider-context'
|
|
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
|
import { cn } from '@/utils/classnames'
|
|
import { supportFunctionCall } from '@/utils/tool-call'
|
|
import { getMarketplaceUrl } from '@/utils/var'
|
|
import { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations'
|
|
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
|
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
|
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
|
|
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
|
|
import PopupItem from './popup-item'
|
|
|
|
type PopupProps = {
|
|
defaultModel?: DefaultModel
|
|
modelList: Model[]
|
|
onSelect: (provider: string, model: ModelItem) => void
|
|
scopeFeatures?: ModelFeatureEnum[]
|
|
onHide: () => void
|
|
}
|
|
const Popup: FC<PopupProps> = ({
|
|
defaultModel,
|
|
modelList,
|
|
onSelect,
|
|
scopeFeatures = [],
|
|
onHide,
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
const { theme } = useTheme()
|
|
const language = useLanguage()
|
|
const [searchText, setSearchText] = useState('')
|
|
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
|
|
const { setShowAccountSettingModal } = useModalContext()
|
|
const { modelProviders } = useProviderContext()
|
|
const {
|
|
plugins: allPlugins,
|
|
isLoading: isMarketplacePluginsLoading,
|
|
} = useMarketplaceAllPlugins(modelProviders, '')
|
|
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
|
const { refreshPluginList } = useRefreshPluginList()
|
|
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
|
|
const { isExhausted: isCreditsExhausted } = useTrialCredits()
|
|
const showCreditsExhaustedAlert = useMemo(() => {
|
|
return isCreditsExhausted && modelProviders.some(p => p.system_configuration.enabled && IS_CLOUD_EDITION)
|
|
}, [isCreditsExhausted, modelProviders])
|
|
const hasApiKeyFallback = useMemo(() => {
|
|
return modelProviders.some((p) => {
|
|
const isApiKeyActive = p.custom_configuration?.status === CustomConfigurationStatusEnum.active
|
|
const supportsCredits = p.system_configuration.enabled && IS_CLOUD_EDITION
|
|
return isApiKeyActive && supportsCredits
|
|
})
|
|
}, [modelProviders])
|
|
|
|
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
|
|
if (!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, 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 = modelProviders.find(provider => provider.provider === providerKey)
|
|
|
|
if (!installedProvider)
|
|
return []
|
|
|
|
const matchedModel = modelMap.get(providerKey)
|
|
return matchedModel ? [matchedModel] : []
|
|
})
|
|
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
|
|
|
|
return [...installedMarketplaceModels, ...otherModels]
|
|
}, [modelList, modelProviders])
|
|
|
|
const filteredModelList = useMemo(() => {
|
|
const filtered = installedModelList.map((model) => {
|
|
const matchesProviderSearch = !searchText
|
|
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|
|
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
|
|
|
|
const filteredModels = model.models
|
|
.filter((modelItem) => {
|
|
if (modelItem.label[language] !== undefined)
|
|
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
|
|
return Object.values(modelItem.label).some(label =>
|
|
label.toLowerCase().includes(searchText.toLowerCase()),
|
|
)
|
|
})
|
|
.filter((modelItem) => {
|
|
if (scopeFeatures.length === 0)
|
|
return true
|
|
return scopeFeatures.every((feature) => {
|
|
if (feature === ModelFeatureEnum.toolCall)
|
|
return supportFunctionCall(modelItem.features)
|
|
return modelItem.features?.includes(feature) ?? false
|
|
})
|
|
})
|
|
if (!matchesProviderSearch || filteredModels.length === 0)
|
|
return null
|
|
|
|
return { ...model, models: filteredModels }
|
|
}).filter((model): model is Model => model !== null)
|
|
|
|
if (defaultModel?.provider) {
|
|
filtered.sort((a, b) => {
|
|
const aSelected = a.provider === defaultModel.provider ? 0 : 1
|
|
const bSelected = b.provider === defaultModel.provider ? 0 : 1
|
|
return aSelected - bSelected
|
|
})
|
|
}
|
|
|
|
return filtered
|
|
}, [defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
|
|
|
|
const marketplaceProviders = useMemo(() => {
|
|
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
|
|
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
|
|
}, [modelProviders])
|
|
|
|
return (
|
|
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
|
|
<div className="sticky top-0 z-10 bg-components-panel-bg pb-1 pl-3 pr-2 pt-3">
|
|
<div className={`
|
|
flex h-8 items-center rounded-lg border pl-[9px] pr-[10px]
|
|
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
|
|
`}
|
|
>
|
|
<span
|
|
className={`
|
|
i-ri-search-line mr-[7px] h-[14px] w-[14px] shrink-0
|
|
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
|
|
`}
|
|
/>
|
|
<input
|
|
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none"
|
|
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
|
|
value={searchText}
|
|
onChange={e => setSearchText(e.target.value)}
|
|
/>
|
|
{
|
|
searchText && (
|
|
<span
|
|
className="i-custom-vender-solid-general-x-circle ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
|
|
onClick={() => setSearchText('')}
|
|
/>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
{showCreditsExhaustedAlert && (
|
|
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
|
)}
|
|
<div className="px-1 pb-1">
|
|
{
|
|
filteredModelList.map(model => (
|
|
<PopupItem
|
|
key={model.provider}
|
|
defaultModel={defaultModel}
|
|
model={model}
|
|
onSelect={onSelect}
|
|
onHide={onHide}
|
|
/>
|
|
))
|
|
}
|
|
{!filteredModelList.length && !installedModelList.length && (
|
|
<div className="flex flex-col gap-2 rounded-[10px] bg-gradient-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
|
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-text-secondary system-sm-medium">
|
|
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
|
|
</p>
|
|
<p className="text-text-tertiary system-xs-regular">
|
|
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="primary"
|
|
className="w-[108px]"
|
|
onClick={() => {
|
|
onHide()
|
|
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
|
}}
|
|
>
|
|
{t('modelProvider.selector.configure', { ns: 'common' })}
|
|
<span className="i-ri-arrow-right-line h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{!filteredModelList.length && installedModelList.length > 0 && (
|
|
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
|
|
{`No model found for \u201C${searchText}\u201D`}
|
|
</div>
|
|
)}
|
|
{marketplaceProviders.length > 0 && (
|
|
<>
|
|
<div className="mx-2 my-1 border-t border-divider-subtle" />
|
|
<div className="mb-1">
|
|
<div className="flex h-[22px] items-center px-3">
|
|
<div
|
|
className="flex flex-1 cursor-pointer items-center text-text-primary system-sm-medium"
|
|
onClick={() => setMarketplaceCollapsed(prev => !prev)}
|
|
>
|
|
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
|
|
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
|
|
</div>
|
|
</div>
|
|
{!marketplaceCollapsed && (
|
|
<>
|
|
{marketplaceProviders.map((key) => {
|
|
const Icon = providerIconMap[key]
|
|
const isInstalling = installingProvider === key
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pl-3 pr-0.5 hover:bg-state-base-hover"
|
|
>
|
|
<div className="flex flex-1 items-center gap-2 py-0.5">
|
|
<Icon className="h-5 w-5 shrink-0 rounded-md" />
|
|
<span className="text-text-secondary system-sm-regular">{modelNameMap[key]}</span>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="small"
|
|
className={cn(
|
|
'shrink-0 backdrop-blur-[5px]',
|
|
!isInstalling && 'hidden group-hover:flex',
|
|
)}
|
|
disabled={isInstalling || isMarketplacePluginsLoading}
|
|
onClick={() => handleInstallPlugin(key)}
|
|
>
|
|
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
|
{isInstalling
|
|
? t('installModal.installing', { ns: 'plugin' })
|
|
: t('modelProvider.selector.install', { ns: 'common' })}
|
|
</Button>
|
|
</div>
|
|
)
|
|
})}
|
|
<a
|
|
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
|
|
href={getMarketplaceUrl('', { theme })}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<span className="flex-1 text-text-accent system-xs-regular">
|
|
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
|
</span>
|
|
<span className="i-ri-arrow-right-up-line !h-3 !w-3 text-text-accent" />
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
|
|
onClick={() => {
|
|
onHide()
|
|
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
|
}}
|
|
>
|
|
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
|
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Popup
|