From b94ad084c360feccd164def59e4005936382fd34 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:33:02 +0800 Subject: [PATCH] feat: surface featured trigger recommendations in start tab (#27319) --- .../block-selector/all-start-blocks.tsx | 64 +++- .../block-selector/featured-triggers.tsx | 326 ++++++++++++++++++ web/i18n/en-US/workflow.ts | 2 + web/i18n/ja-JP/workflow.ts | 3 + web/i18n/zh-Hans/workflow.ts | 2 + web/i18n/zh-Hant/workflow.ts | 4 + web/service/use-plugins.ts | 26 +- 7 files changed, 417 insertions(+), 10 deletions(-) create mode 100644 web/app/components/workflow/block-selector/featured-triggers.tsx diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx index c700dd8b75..6da05048b8 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, + useMemo, + useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' import type { BlockEnum, OnSelectBlock } from '../types' -import type { TriggerDefaultValue } from './types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' import StartBlocks from './start-blocks' import TriggerPluginList from './trigger-plugin/list' import { ENTRY_NODE_TYPES } from './constants' @@ -17,6 +19,11 @@ import { getMarketplaceUrl } from '@/utils/var' import Button from '@/app/components/base/button' import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' import { BlockEnum as BlockEnumValue } from '../types' +import FeaturedTriggers from './featured-triggers' +import Divider from '@/app/components/base/divider' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' +import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' @@ -38,11 +45,39 @@ const AllStartBlocks = ({ const { t } = useTranslation() const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false) const [hasPluginContent, setHasPluginContent] = useState(false) + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const entryNodeTypes = availableBlocksTypes?.length ? availableBlocksTypes : ENTRY_NODE_TYPES const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin) + const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin) + const providerMap = useMemo(() => { + const map = new Map() + triggerProviders.forEach((provider) => { + const keys = [ + provider.plugin_id, + provider.plugin_unique_identifier, + provider.id, + ].filter(Boolean) as string[] + keys.forEach((key) => { + if (!map.has(key)) + map.set(key, provider) + }) + }) + return map + }, [triggerProviders]) + const invalidateTriggers = useInvalidateAllTriggerPlugins() + const trimmedSearchText = searchText.trim() + const hasSearchText = trimmedSearchText.length > 0 + const { + plugins: featuredPlugins = [], + isLoading: featuredLoading, + } = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasSearchText) + + const shouldShowFeatured = enableTriggerPlugin + && enable_marketplace + && !hasSearchText const handleStartBlocksContentChange = useCallback((hasContent: boolean) => { setHasStartBlocksContent(hasContent) @@ -52,8 +87,8 @@ const AllStartBlocks = ({ setHasPluginContent(hasContent) }, []) - const hasAnyContent = hasStartBlocksContent || hasPluginContent - const shouldShowEmptyState = searchText && !hasAnyContent + const hasAnyContent = hasStartBlocksContent || hasPluginContent || shouldShowFeatured + const shouldShowEmptyState = hasSearchText && !hasAnyContent useEffect(() => { if (!enableTriggerPlugin && hasPluginContent) @@ -65,8 +100,27 @@ const AllStartBlocks = ({
+ {shouldShowFeatured && ( + <> + { + invalidateTriggers() + }} + /> +
+ +
+ + )} +
+ {t('workflow.tabs.allTriggers')} +
diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx new file mode 100644 index 0000000000..561ebc1784 --- /dev/null +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -0,0 +1,326 @@ +'use client' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum } from '../types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' +import type { Plugin } from '@/app/components/plugins/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../block-icon' +import Tooltip from '@/app/components/base/tooltip' +import { RiMoreLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import Link from 'next/link' +import { getMarketplaceUrl } from '@/utils/var' +import TriggerPluginItem from './trigger-plugin/item' +import { formatNumber } from '@/utils/format' +import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' + +const MAX_RECOMMENDED_COUNT = 15 +const INITIAL_VISIBLE_COUNT = 5 + +type FeaturedTriggersProps = { + plugins: Plugin[] + providerMap: Map + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + isLoading?: boolean + onInstallSuccess?: () => void | Promise +} + +const STORAGE_KEY = 'workflow_triggers_featured_collapsed' + +const FeaturedTriggers = ({ + plugins, + providerMap, + onSelect, + isLoading = false, + onInstallSuccess, +}: FeaturedTriggersProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [isCollapsed, setIsCollapsed] = useState(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const limitedPlugins = useMemo( + () => plugins.slice(0, MAX_RECOMMENDED_COUNT), + [plugins], + ) + + const { + installedProviders, + uninstalledPlugins, + } = useMemo(() => { + const installed: TriggerWithProvider[] = [] + const uninstalled: Plugin[] = [] + const visitedProviderIds = new Set() + + limitedPlugins.forEach((plugin) => { + const provider = providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier) + if (provider) { + if (!visitedProviderIds.has(provider.id)) { + installed.push(provider) + visitedProviderIds.add(provider.id) + } + } + else { + uninstalled.push(plugin) + } + }) + + return { + installedProviders: installed, + uninstalledPlugins: uninstalled, + } + }, [limitedPlugins, providerMap]) + + const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT) + + const visibleInstalledProviders = useMemo( + () => installedProviders.slice(0, totalQuota), + [installedProviders, totalQuota], + ) + + const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0) + + const visibleUninstalledPlugins = useMemo( + () => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []), + [uninstalledPlugins, remainingSlots], + ) + + const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length + const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const hasMoreToShow = totalVisible < maxAvailable + const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT + const isExpanded = canToggleVisibility && !hasMoreToShow + const showEmptyState = !isLoading && totalVisible === 0 + + return ( +
+ + + {!isCollapsed && ( + <> + {isLoading && ( +
+ +
+ )} + + {showEmptyState && ( +

+ + {t('workflow.tabs.noFeaturedTriggers')} + +

+ )} + + {!showEmptyState && !isLoading && ( + <> + {visibleInstalledProviders.length > 0 && ( +
+ {visibleInstalledProviders.map(provider => ( + + ))} +
+ )} + + {visibleUninstalledPlugins.length > 0 && ( +
+ {visibleUninstalledPlugins.map(plugin => ( + { + await onInstallSuccess?.() + }} + t={t} + /> + ))} +
+ )} + + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( +
{ + setVisibleCount((count) => { + if (count >= maxAvailable) + return INITIAL_VISIBLE_COUNT + + return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) + }) + }} + > +
+ + {isExpanded ? ( + + ) : ( + + )} +
+
+ {t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')} +
+
+ )} + + )} +
+ ) +} + +type FeaturedTriggerUninstalledItemProps = { + plugin: Plugin + language: string + onInstallSuccess?: () => Promise | void + t: (key: string, options?: Record) => string +} + +function FeaturedTriggerUninstalledItem({ + plugin, + language, + onInstallSuccess, + t, +}: FeaturedTriggerUninstalledItemProps) { + const label = plugin.label?.[language] || plugin.name + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) }) + const [actionOpen, setActionOpen] = useState(false) + const [isActionHovered, setIsActionHovered] = useState(false) + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) + + useEffect(() => { + if (!actionOpen) + return + + const handleScroll = () => { + setActionOpen(false) + setIsActionHovered(false) + } + + window.addEventListener('scroll', handleScroll, true) + + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [actionOpen]) + + return ( + <> + + +
{label}
+
{description}
+
+ )} + disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} + > +
+
+ +
+
{label}
+
+
+
+ {installCountLabel} +
setIsActionHovered(true)} + onMouseLeave={() => { + if (!actionOpen) + setIsActionHovered(false) + }} + > + + { + setActionOpen(value) + setIsActionHovered(value) + }} + author={plugin.org} + name={plugin.name} + version={plugin.latest_version} + /> +
+
+
+ + {isInstallModalOpen && ( + { + setIsInstallModalOpen(false) + setIsActionHovered(false) + await onInstallSuccess?.() + }} + onClose={() => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + }} + /> + )} + + ) +} + +export default FeaturedTriggers diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 10105c5fd1..17fe37b12f 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -260,6 +260,7 @@ const translation = { 'blocks': 'Nodes', 'searchTool': 'Search tool', 'searchTrigger': 'Search triggers...', + 'allTriggers': 'All triggers', 'tools': 'Tools', 'allTool': 'All', 'plugin': 'Plugin', @@ -285,6 +286,7 @@ const translation = { 'usePlugin': 'Select tool', 'hideActions': 'Hide tools', 'noFeaturedPlugins': 'Discover more tools in Marketplace', + 'noFeaturedTriggers': 'Discover more triggers in Marketplace', }, blocks: { 'start': 'User Input', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 97fb916b2c..1b432e35da 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -243,6 +243,7 @@ const translation = { 'searchTool': 'ツール検索', 'searchTrigger': 'トリガー検索...', 'tools': 'ツール', + 'allTriggers': 'すべてのトリガー', 'allTool': 'すべて', 'customTool': 'カスタム', 'workflowTool': 'ワークフロー', @@ -255,6 +256,8 @@ const translation = { 'requestToCommunity': 'コミュニティにリクエスト', 'plugin': 'プラグイン', 'agent': 'エージェント戦略', + 'noFeaturedPlugins': 'マーケットプレイスでさらにツールを見つける', + 'noFeaturedTriggers': 'マーケットプレイスでさらにトリガーを見つける', 'addAll': 'すべてを追加する', 'allAdded': 'すべて追加されました', 'searchDataSource': 'データソースを検索', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 8ae9e07fa3..3f933abcf2 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -245,6 +245,7 @@ const translation = { 'blocks': '节点', 'searchTool': '搜索工具', 'searchTrigger': '搜索触发器...', + 'allTriggers': '全部触发器', 'tools': '工具', 'allTool': '全部', 'plugin': '插件', @@ -271,6 +272,7 @@ const translation = { 'usePlugin': '选择工具', 'hideActions': '收起工具', 'noFeaturedPlugins': '前往插件市场查看更多工具', + 'noFeaturedTriggers': '前往插件市场查看更多触发器', }, blocks: { 'start': '用户输入', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 98290cbfce..880352360f 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -222,6 +222,8 @@ const translation = { 'searchBlock': '搜索節點', 'blocks': '節點', 'tools': '工具', + 'searchTrigger': '搜尋觸發器...', + 'allTriggers': '所有觸發器', 'allTool': '全部', 'customTool': '自定義', 'workflowTool': '工作流', @@ -237,6 +239,8 @@ const translation = { 'addAll': '全部添加', 'sources': '來源', 'searchDataSource': '搜尋資料來源', + 'noFeaturedPlugins': '前往 Marketplace 查看更多工具', + 'noFeaturedTriggers': '前往 Marketplace 查看更多觸發器', }, blocks: { 'start': '開始', diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 9319ee7106..05badfc220 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -69,22 +69,21 @@ export const useCheckInstalled = ({ const useRecommendedMarketplacePluginsKey = [NAME_SPACE, 'recommendedMarketplacePlugins'] export const useRecommendedMarketplacePlugins = ({ - category = PluginCategoryEnum.tool, + collection = '__recommended-plugins-tools', enabled = true, limit = 15, }: { - category?: string + collection?: string enabled?: boolean limit?: number } = {}) => { return useQuery({ - queryKey: [...useRecommendedMarketplacePluginsKey, category, limit], + queryKey: [...useRecommendedMarketplacePluginsKey, collection, limit], queryFn: async () => { const response = await postMarketplace<{ data: { plugins: Plugin[] } }>( - '/collections/__recommended-plugins-overall/plugins', + `/collections/${collection}/plugins`, { body: { - category, limit, }, }, @@ -101,6 +100,23 @@ export const useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => data: plugins = [], isLoading, } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-tools', + enabled, + limit, + }) + + return { + plugins, + isLoading, + } +} + +export const useFeaturedTriggersRecommendations = (enabled: boolean, limit = 15) => { + const { + data: plugins = [], + isLoading, + } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-triggers', enabled, limit, })