dify/web/app/components/integrations/tool-provider-list.tsx
Jingyi 9b74df21d0
feat(web): refine onboarding UI (#37433)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-15 08:47:15 +00:00

275 lines
11 KiB
TypeScript

'use client'
import type { ReactNode, RefObject } from 'react'
import type { ToolCategory } from '@/app/components/integrations/routes'
import type { ToolsContentInset } from '@/app/components/tools/content-inset'
import type { Collection } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isSearchResultEmpty } from '@/app/components/base/search-input/search-state'
import { useTags } from '@/app/components/plugins/hooks'
import Empty from '@/app/components/plugins/marketplace/empty'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { useCanSetPluginSettings } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '@/app/components/tools/content-inset'
import Marketplace from '@/app/components/tools/marketplace'
import MCPList from '@/app/components/tools/mcp'
import ProviderDetail from '@/app/components/tools/provider/detail'
import { ToolProviderGrid } from '@/app/components/tools/tool-provider-grid'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { useToolMarketplacePanel } from './hooks/use-tool-marketplace-panel'
import { useToolProviderCategory } from './hooks/use-tool-provider-category'
import ToolProviderCreateAction from './tool-provider-create-action'
import { ToolProviderToolbar } from './tool-provider-toolbar'
type ProviderListProps = {
category?: ToolCategory
contentInset?: ToolsContentInset
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
}
type BuiltinMarketplacePanelProps = {
containerRef: RefObject<HTMLDivElement | null>
contentInset: ToolsContentInset
keywords: string
tagFilterValue: string[]
}
const BuiltinMarketplacePanel = ({
containerRef,
contentInset,
keywords,
tagFilterValue,
}: BuiltinMarketplacePanelProps) => {
const {
isMarketplaceArrowVisible,
marketplaceContext,
showMarketplacePanel,
toolListTailRef,
} = useToolMarketplacePanel({
containerRef,
keywords,
tagFilterValue,
})
return (
<>
<div ref={toolListTailRef} />
<Marketplace
searchPluginText={keywords}
filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
contentInset={contentInset}
/>
</>
)
}
const ProviderList = ({
category,
contentInset = 'default',
layout,
}: ProviderListProps) => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'
const { t } = useTranslation()
const { getTagLabel } = useTags()
const {
canSetPermissions,
} = useCanSetPluginSettings()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const { activeTab, handleCategoryChange, isRouteCategory } = useToolProviderCategory(category)
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
const toolListFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName)
const showToolsUpdateSetting = activeTab === 'builtin' && canSetPermissions
const showLabelFilter = activeTab === 'builtin'
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
{ value: 'workflow', text: t('type.workflow', { ns: 'tools' }) },
{ value: 'mcp', text: 'MCP' },
]
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
}
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const [createdMCPProviderId, setCreatedMCPProviderId] = useState<string>()
const handleMCPProviderCreated = useCallback((providerId: string) => {
setCreatedMCPProviderId(providerId)
}, [])
const handleCreatedMCPProviderHandled = useCallback(() => {
setCreatedMCPProviderId(undefined)
}, [])
const { data: collectionList = [], isLoading: isCollectionListLoading, refetch } = useAllToolProviders()
const activeTabCollectionList = useMemo(() => {
return collectionList.filter(collection => collection.type === activeTab)
}, [activeTab, collectionList])
const hasCategoryCollections = activeTabCollectionList.length > 0
const shouldShowCustomToolCreateCard = !(activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections)
const shouldShowMCPCreateCard = !(activeTab === 'mcp' && hasCategoryCollections)
const shouldShowToolbarCreateAction
= (activeTab === 'mcp' && hasCategoryCollections)
|| (activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections)
const filteredCollectionList = useMemo(() => {
return activeTabCollectionList.filter((collection) => {
if (showLabelFilter && tagFilterValue.length > 0 && (!collection.labels || collection.labels.every(label => !tagFilterValue.includes(label))))
return false
if (keywords)
return Object.values(collection.label).some(value => value.toLowerCase().includes(keywords.toLowerCase()))
return true
})
}, [activeTabCollectionList, showLabelFilter, tagFilterValue, keywords])
const isFilteringCollections = !!keywords.trim() || (showLabelFilter && tagFilterValue.length > 0)
const isCollectionSearchEmpty = isSearchResultEmpty({
hasActiveFilter: isFilteringCollections,
isLoading: isCollectionListLoading,
resultCount: filteredCollectionList.length,
sourceCount: activeTabCollectionList.length,
})
const [currentProviderId, setCurrentProviderId] = useState<string | undefined>()
const currentProvider = useMemo<Collection | undefined>(() => {
return filteredCollectionList.find(collection => collection.id === currentProviderId)
}, [currentProviderId, filteredCollectionList])
const { data: checkedInstalledData } = useCheckInstalled({
pluginIds: currentProvider?.plugin_id ? [currentProvider.plugin_id] : [],
enabled: !!currentProvider?.plugin_id,
})
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const currentPluginDetail = useMemo(() => {
return checkedInstalledData?.plugins?.[0]
}, [checkedInstalledData])
const containerRef = useRef<HTMLDivElement>(null)
const toolbar = (
<ToolProviderToolbar
activeTab={activeTab}
currentProviderId={currentProviderId}
frameClassName={layout ? undefined : toolListFrameClassName}
isRouteCategory={isRouteCategory}
keywords={keywords}
options={options}
showLabelFilter={showLabelFilter}
showToolsUpdateSetting={showToolsUpdateSetting}
tagFilterValue={tagFilterValue}
toolbarAction={shouldShowToolbarCreateAction
? (
<ToolProviderCreateAction
activeTab={activeTab}
hasCategoryCollections={hasCategoryCollections}
isCollectionListLoading={isCollectionListLoading}
onCustomToolCreated={refetch}
onMCPProviderCreated={handleMCPProviderCreated}
/>
)
: undefined}
onCategoryChange={state => handleCategoryChange(state, () => setCurrentProviderId(undefined))}
onKeywordsChange={handleKeywordsChange}
onTagsChange={handleTagsChange}
/>
)
const body = (
<>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-hidden bg-components-panel-bg">
<ScrollAreaRoot className="relative min-h-0 grow overflow-hidden bg-components-panel-bg">
<ScrollAreaViewport
ref={containerRef}
aria-label={t('menus.tools', { ns: 'common' })}
className="overscroll-contain"
role="region"
>
<ScrollAreaContent className="flex min-h-full flex-col">
{activeTab !== 'mcp' && (
<ToolProviderGrid
activeTab={activeTab}
collections={filteredCollectionList}
currentProviderId={currentProviderId}
frameClassName={toolListFrameClassName}
getTagLabel={getTagLabel}
hasCategoryCollections={activeTabCollectionList.length > 0}
isLoading={isCollectionListLoading}
useIntegrationsCard={contentInset === 'compact'}
isSearchResultEmpty={isCollectionSearchEmpty}
showCreateCard={shouldShowCustomToolCreateCard}
onRefreshData={refetch}
onSelectProvider={setCurrentProviderId}
/>
)}
{!isCollectionListLoading && !activeTabCollectionList.length && activeTab === 'builtin' && (
<Empty lightCard text={t('noTools', { ns: 'tools' })} className={cn('h-[224px] shrink-0', toolListFrameClassName)} />
)}
{isCollectionSearchEmpty && activeTab === 'builtin' && (
<div className={cn('h-[224px] shrink-0', toolListFrameClassName)} />
)}
{enable_marketplace && activeTab === 'builtin' && (
<BuiltinMarketplacePanel
containerRef={containerRef}
contentInset={contentInset}
keywords={keywords}
tagFilterValue={tagFilterValue}
/>
)}
{activeTab === 'mcp' && (
<MCPList
searchText={keywords}
contentInset={contentInset}
createdProviderId={createdMCPProviderId}
showCreateCard={shouldShowMCPCreateCard}
onCreatedProviderHandled={handleCreatedMCPProviderHandled}
/>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
{currentProvider && !currentProvider.plugin_id && (
<ProviderDetail
collection={currentProvider}
onHide={() => setCurrentProviderId(undefined)}
onRefreshData={refetch}
/>
)}
<PluginDetailPanel
detail={currentPluginDetail}
onUpdate={() => invalidateInstalledPluginList()}
onHide={() => setCurrentProviderId(undefined)}
/>
</>
)
if (layout)
return layout({ body, toolbar })
return (
<>
{toolbar}
{body}
</>
)
}
ProviderList.displayName = 'ToolProviderList'
export default ProviderList