diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index dabb8f4a9a..d86795826a 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -29,6 +29,9 @@ import { PluginCategoryEnum } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' import RAGToolSuggestions from './rag-tool-suggestions' +import FeaturedTools from './featured-tools' +import { useCheckInstalled, useRecommendedMarketplacePlugins } from '@/service/use-plugins' +import { useInvalidateAllBuiltInTools } from '@/service/use-tools' import Link from 'next/link' type AllToolsProps = { @@ -80,6 +83,16 @@ const AllTools = ({ const isMatchingKeywords = (text: string, keywords: string) => { return text.toLowerCase().includes(keywords.toLowerCase()) } + const allProviders = useMemo(() => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], [buildInTools, customTools, workflowTools, mcpTools]) + const providerMap = useMemo(() => { + const map = new Map() + allProviders.forEach((provider) => { + const key = provider.plugin_id || provider.id + if (key) + map.set(key, provider) + }) + return map + }, [allProviders]) const tools = useMemo(() => { let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) @@ -136,6 +149,27 @@ const AllTools = ({ } = useMarketplacePlugins() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const { + data: recommendedPlugins = [], + isLoading: isLoadingRecommended, + } = useRecommendedMarketplacePlugins({ + enabled: enable_marketplace, + }) + const recommendedPluginIds = useMemo( + () => recommendedPlugins.map(plugin => plugin.plugin_id), + [recommendedPlugins], + ) + const installedCheck = useCheckInstalled({ + pluginIds: recommendedPluginIds, + enabled: recommendedPluginIds.length > 0, + }) + const installedPluginIds = useMemo( + () => new Set(installedCheck.data?.plugins.map(plugin => plugin.plugin_id) ?? []), + [installedCheck.data], + ) + const loadingRecommendedInstallStatus = installedCheck.isLoading || installedCheck.isRefetching + const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + useEffect(() => { if (!enable_marketplace) return if (hasFilter) { @@ -155,6 +189,11 @@ const AllTools = ({ const hasToolsContent = tools.length > 0 const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0 const shouldShowEmptyState = hasFilter && !hasToolsContent && !hasPluginContent + const shouldShowFeatured = enable_marketplace + && activeTab === ToolTypeEnum.All + && !hasFilter + && !isLoadingRecommended + && recommendedPlugins.length > 0 return (
@@ -193,6 +232,21 @@ const AllTools = ({ onTagsChange={onTagsChange} /> )} + {shouldShowFeatured && ( + { + invalidateBuiltInTools() + await installedCheck.refetch() + }} + /> + )} + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void + selectedTools?: ToolValue[] + canChooseMCPTool?: boolean + installedPluginIds: Set + loadingInstalledStatus: boolean + onInstallSuccess?: () => void +} + +function isToolSelected(tool: Tool, provider: ToolWithProvider, selectedTools?: ToolValue[]): boolean { + if (!selectedTools || !selectedTools.length) + return false + return selectedTools.some(item => (item.provider_name === provider.name || item.provider_name === provider.id) && item.tool_name === tool.name) +} + +const FeaturedTools = ({ + plugins, + providerMap, + onSelect, + selectedTools, + canChooseMCPTool, + installedPluginIds, + loadingInstalledStatus, + onInstallSuccess, +}: FeaturedToolsProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [installingIdentifier, setInstallingIdentifier] = useState(null) + const installMutation = useInstallPackageFromMarketPlace({ + onSuccess: () => { + onInstallSuccess?.() + }, + onSettled: () => { + setInstallingIdentifier(null) + }, + }) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const visiblePlugins = useMemo( + () => plugins.slice(0, Math.min(MAX_RECOMMENDED_COUNT, visibleCount)), + [plugins, visibleCount], + ) + + if (!visiblePlugins.length) + return null + + const showMore = visibleCount < Math.min(MAX_RECOMMENDED_COUNT, plugins.length) + + return ( +
+
+ {t('workflow.tabs.featuredTools')} +
+
+ {visiblePlugins.map(plugin => renderFeaturedToolItem({ + plugin, + providerMap, + installedPluginIds, + installMutationPending: installMutation.isPending, + installingIdentifier, + loadingInstalledStatus, + canChooseMCPTool, + onSelect, + selectedTools, + language, + installPlugin: installMutation.mutate, + setInstallingIdentifier, + }))} +
+ {showMore && ( + + )} +
+ ) +} + +type FeaturedToolItemProps = { + plugin: Plugin + provider: ToolWithProvider | undefined + isInstalled: boolean + installDisabled: boolean + canChooseMCPTool?: boolean + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void + selectedTools?: ToolValue[] + language: string + onInstall: () => void + isInstalling: boolean +} + +function FeaturedToolItem({ + plugin, + provider, + isInstalled, + installDisabled, + canChooseMCPTool, + onSelect, + selectedTools, + language, + onInstall, + isInstalling, +}: FeaturedToolItemProps) { + const { t } = useTranslation() + const [isExpanded, setExpanded] = useState(false) + const hasProvider = Boolean(provider) + const installCountLabel = t('plugin.install', { num: plugin.install_count?.toLocaleString() ?? 0 }) + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + + useEffect(() => { + if (!hasProvider) + setExpanded(false) + }, [hasProvider]) + + let toggleLabel: string + if (!hasProvider) + toggleLabel = t('workflow.common.syncingData') + else if (isExpanded) + toggleLabel = t('workflow.tabs.hideActions') + else + toggleLabel = t('workflow.tabs.usePlugin') + + return ( +
+
+ +
+
+
+ {plugin.label?.[language] || plugin.name} +
+ {isInstalled && ( + + {t('workflow.tabs.installed')} + + )} +
+
+ {description} +
+
+ {installCountLabel} + {plugin.org && {t('workflow.tabs.pluginByAuthor', { author: plugin.org })}} +
+
+
+ {!isInstalled && ( + + )} + {isInstalled && ( + + )} +
+
+ {isInstalled && hasProvider && isExpanded && ( +
+ {provider.tools.map((tool) => { + const isSelected = isToolSelected(tool, provider, selectedTools) + const isMCPTool = provider.type === CollectionType.mcp + const disabled = isSelected || (!canChooseMCPTool && isMCPTool) + + return ( + + ) + })} +
+ )} +
+ ) +} + +type RenderFeaturedToolParams = { + plugin: Plugin + providerMap: Map + installedPluginIds: Set + installMutationPending: boolean + installingIdentifier: string | null + loadingInstalledStatus: boolean + canChooseMCPTool?: boolean + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void + selectedTools?: ToolValue[] + language: string + installPlugin: (uniqueIdentifier: string) => void + setInstallingIdentifier: (identifier: string | null) => void +} + +function renderFeaturedToolItem({ + plugin, + providerMap, + installedPluginIds, + installMutationPending, + installingIdentifier, + loadingInstalledStatus, + canChooseMCPTool, + onSelect, + selectedTools, + language, + installPlugin, + setInstallingIdentifier, +}: RenderFeaturedToolParams) { + const provider = providerMap.get(plugin.plugin_id) + const isInstalled = installedPluginIds.has(plugin.plugin_id) + const isInstalling = installMutationPending && installingIdentifier === plugin.latest_package_identifier + + return ( + { + if (installMutationPending) + return + setInstallingIdentifier(plugin.latest_package_identifier) + installPlugin(plugin.latest_package_identifier) + }} + isInstalling={isInstalling} + /> + ) +} + +export default FeaturedTools diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index a967a82bb2..e26ff5e859 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -277,6 +277,12 @@ const translation = { 'addAll': 'Add all', 'sources': 'Sources', 'searchDataSource': 'Search Data Source', + 'featuredTools': 'Featured', + 'showMoreFeatured': 'Show more', + 'installed': 'Installed', + 'pluginByAuthor': 'By {{author}}', + 'usePlugin': 'Select tool', + 'hideActions': 'Hide tools', }, blocks: { 'start': 'User Input', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index a7206db9df..90a2d915b9 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -263,6 +263,12 @@ const translation = { 'sources': '数据源', 'searchDataSource': '搜索数据源', 'start': '开始', + 'featuredTools': '精选推荐', + 'showMoreFeatured': '查看更多', + 'installed': '已安装', + 'pluginByAuthor': '来自 {{author}}', + 'usePlugin': '选择工具', + 'hideActions': '收起工具', }, blocks: { 'start': '用户输入', diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 21a3cc00fd..fcd626b58e 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -43,6 +43,7 @@ import useReferenceSetting from '@/app/components/plugins/plugin-page/use-refere import { uninstallPlugin } from '@/service/plugins' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { cloneDeep } from 'lodash-es' +import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' const NAME_SPACE = 'plugins' @@ -66,6 +67,35 @@ export const useCheckInstalled = ({ }) } +const useRecommendedMarketplacePluginsKey = [NAME_SPACE, 'recommendedMarketplacePlugins'] +export const useRecommendedMarketplacePlugins = ({ + category = PluginCategoryEnum.tool, + enabled = true, + limit = 15, +}: { + category?: string + enabled?: boolean + limit?: number +} = {}) => { + return useQuery({ + queryKey: [...useRecommendedMarketplacePluginsKey, category, limit], + queryFn: async () => { + const response = await postMarketplace<{ data: { plugins: Plugin[] } }>( + '/collections/__recommended-plugins-overall/plugins', + { + body: { + category, + limit, + }, + }, + ) + return response.data.plugins.map(plugin => getFormattedPlugin(plugin)) + }, + enabled, + staleTime: 60 * 1000, + }) +} + export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => { const fetchPlugins = async ({ pageParam = 1 }) => { const response = await get(