Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger

This commit is contained in:
zhsama 2025-10-22 12:49:41 +08:00
commit d101a83be8
37 changed files with 463 additions and 91 deletions

View File

@ -196,7 +196,7 @@ class AsyncWorkflowConfig(BaseSettings):
"to avoid this, workflow can be suspended if needed, to achieve"
"this, a time-based checker is required, every granularity seconds, "
"the checker will check the workflow queue and suspend the workflow",
default=1,
default=60,
ge=1,
)

View File

@ -1,5 +1,6 @@
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events.base import GraphEngineEvent
from core.workflow.graph_events.graph import GraphRunPausedEvent
class SuspendLayer(GraphEngineLayer):
@ -9,7 +10,11 @@ class SuspendLayer(GraphEngineLayer):
pass
def on_event(self, event: GraphEngineEvent):
pass
"""
Handle the paused event, stash runtime state into storage and wait for resume.
"""
if isinstance(event, GraphRunPausedEvent):
pass
def on_graph_end(self, error: Exception | None):
""" """

View File

@ -4,7 +4,6 @@ from typing import ClassVar
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
from configs import dify_config
from core.workflow.graph_engine.entities.commands import CommandType, GraphEngineCommand
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events.base import GraphEngineEvent
@ -13,7 +12,7 @@ from services.workflow.scheduler import CFSPlanScheduler, SchedulerCommand
logger = logging.getLogger(__name__)
class TimesliceLayer(GraphEngineLayer):
class TimeSliceLayer(GraphEngineLayer):
"""
CFS plan scheduler to control the timeslice of the workflow.
"""
@ -25,13 +24,43 @@ class TimesliceLayer(GraphEngineLayer):
CFS plan scheduler allows to control the timeslice of the workflow.
"""
if not TimesliceLayer.scheduler.running:
TimesliceLayer.scheduler.start()
if not TimeSliceLayer.scheduler.running:
TimeSliceLayer.scheduler.start()
super().__init__()
self.cfs_plan_scheduler = cfs_plan_scheduler
self.stopped = False
def _checker_job(self, schedule_id: str):
"""
Check if the workflow need to be suspended.
"""
try:
if self.stopped:
self.scheduler.remove_job(schedule_id)
return
if self.cfs_plan_scheduler.can_schedule() == SchedulerCommand.RESOURCE_LIMIT_REACHED:
# remove the job
self.scheduler.remove_job(schedule_id)
if not self.command_channel:
logger.exception("No command channel to stop the workflow")
return
# send command to pause the workflow
self.command_channel.send_command(
GraphEngineCommand(
command_type=CommandType.PAUSE,
payload={
"reason": SchedulerCommand.RESOURCE_LIMIT_REACHED,
},
)
)
except Exception:
logger.exception("scheduler error during check if the workflow need to be suspended")
def on_graph_start(self):
"""
Start timer to check if the workflow need to be suspended.
@ -39,39 +68,11 @@ class TimesliceLayer(GraphEngineLayer):
schedule_id = uuid.uuid4().hex
def runner():
"""
Whenever the workflow is running, keep checking if we need to suspend it.
Otherwise, return directly.
"""
try:
if self.stopped:
self.scheduler.remove_job(schedule_id)
return
if self.cfs_plan_scheduler.can_schedule() == SchedulerCommand.RESOURCE_LIMIT_REACHED:
# remove the job
self.scheduler.remove_job(schedule_id)
if not self.command_channel:
logger.exception("No command channel to stop the workflow")
return
# send command to pause the workflow
self.command_channel.send_command(
GraphEngineCommand(
command_type=CommandType.PAUSE,
payload={
"reason": SchedulerCommand.RESOURCE_LIMIT_REACHED,
},
)
)
except Exception:
logger.exception("scheduler error during check if the workflow need to be suspended")
self.scheduler.add_job(
runner, "interval", seconds=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY, id=schedule_id
lambda: self._checker_job(schedule_id),
"interval",
seconds=self.cfs_plan_scheduler.plan.granularity,
id=schedule_id,
)
def on_event(self, event: GraphEngineEvent):

View File

@ -11,7 +11,7 @@ from core.workflow.graph_events.graph import GraphRunFailedEvent, GraphRunPaused
from models.engine import db
from models.enums import WorkflowTriggerStatus
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from tasks.workflow_cfs_scheduler.cfs_scheduler import TriggerWorkflowCFSPlanEntity
from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity
logger = logging.getLogger(__name__)
@ -29,7 +29,7 @@ class TriggerPostLayer(GraphEngineLayer):
def __init__(
self,
cfs_plan_scheduler_entity: TriggerWorkflowCFSPlanEntity,
cfs_plan_scheduler_entity: AsyncWorkflowCFSPlanEntity,
start_time: datetime,
trigger_log_id: str,
):

View File

@ -12,8 +12,9 @@ from celery import shared_task
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.engine_layers.timeslice_layer import TimesliceLayer
from core.app.engine_layers.timeslice_layer import TimeSliceLayer
from core.app.engine_layers.trigger_post_layer import TriggerPostLayer
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
@ -29,7 +30,7 @@ from services.workflow.entities import (
WorkflowScheduleCFSPlanEntity,
WorkflowTaskData,
)
from tasks.workflow_cfs_scheduler.cfs_scheduler import TriggerCFSPlanScheduler, TriggerWorkflowCFSPlanEntity
from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity, AsyncWorkflowCFSPlanScheduler
from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue
@ -37,12 +38,14 @@ from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue
def execute_workflow_professional(task_data_dict: dict[str, Any]):
"""Execute workflow for professional tier with highest priority"""
task_data = WorkflowTaskData.model_validate(task_data_dict)
cfs_plan_scheduler_entity = TriggerWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE, schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice
cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE,
schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice,
granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY,
)
_execute_workflow_common(
task_data,
TriggerCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
cfs_plan_scheduler_entity,
)
@ -51,12 +54,14 @@ def execute_workflow_professional(task_data_dict: dict[str, Any]):
def execute_workflow_team(task_data_dict: dict[str, Any]):
"""Execute workflow for team tier"""
task_data = WorkflowTaskData.model_validate(task_data_dict)
cfs_plan_scheduler_entity = TriggerWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.TEAM_QUEUE, schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice
cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.TEAM_QUEUE,
schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice,
granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY,
)
_execute_workflow_common(
task_data,
TriggerCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
cfs_plan_scheduler_entity,
)
@ -65,20 +70,22 @@ def execute_workflow_team(task_data_dict: dict[str, Any]):
def execute_workflow_sandbox(task_data_dict: dict[str, Any]):
"""Execute workflow for free tier with lower retry limit"""
task_data = WorkflowTaskData.model_validate(task_data_dict)
cfs_plan_scheduler_entity = TriggerWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.SANDBOX_QUEUE, schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice
cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity(
queue=AsyncWorkflowQueue.SANDBOX_QUEUE,
schedule_strategy=WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice,
granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY,
)
_execute_workflow_common(
task_data,
TriggerCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity),
cfs_plan_scheduler_entity,
)
def _execute_workflow_common(
task_data: WorkflowTaskData,
cfs_plan_scheduler: TriggerCFSPlanScheduler,
cfs_plan_scheduler_entity: TriggerWorkflowCFSPlanEntity,
cfs_plan_scheduler: AsyncWorkflowCFSPlanScheduler,
cfs_plan_scheduler_entity: AsyncWorkflowCFSPlanEntity,
):
"""Execute workflow with common logic and trigger log updates."""
@ -140,7 +147,7 @@ def _execute_workflow_common(
triggered_from=trigger_data.trigger_type,
root_node_id=trigger_data.root_node_id,
layers=[
TimesliceLayer(cfs_plan_scheduler),
TimeSliceLayer(cfs_plan_scheduler),
TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id),
],
)

View File

@ -3,7 +3,7 @@ from services.workflow.scheduler import CFSPlanScheduler, SchedulerCommand
from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue
class TriggerWorkflowCFSPlanEntity(WorkflowScheduleCFSPlanEntity):
class AsyncWorkflowCFSPlanEntity(WorkflowScheduleCFSPlanEntity):
"""
Trigger workflow CFS plan entity.
"""
@ -11,23 +11,22 @@ class TriggerWorkflowCFSPlanEntity(WorkflowScheduleCFSPlanEntity):
queue: AsyncWorkflowQueue
class TriggerCFSPlanScheduler(CFSPlanScheduler):
class AsyncWorkflowCFSPlanScheduler(CFSPlanScheduler):
"""
Trigger workflow CFS plan scheduler.
"""
plan: AsyncWorkflowCFSPlanEntity
def can_schedule(self) -> SchedulerCommand:
"""
Check if the workflow can be scheduled.
"""
assert isinstance(self.plan, TriggerWorkflowCFSPlanEntity)
if self.plan.queue in [AsyncWorkflowQueue.PROFESSIONAL_QUEUE, AsyncWorkflowQueue.TEAM_QUEUE]:
"""
permitted all paid users to schedule the workflow
permitted all paid users to schedule the workflow any time
"""
return SchedulerCommand.NONE
# FIXME: avoid the sandbox user's workflow at a running state for ever
return SchedulerCommand.NONE
return SchedulerCommand.RESOURCE_LIMIT_REACHED

View File

@ -25,10 +25,12 @@ import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { useGetLanguage } from '@/context/i18n'
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import type { Plugin } from '../../plugins/types'
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 Link from 'next/link'
type AllToolsProps = {
@ -47,6 +49,10 @@ type AllToolsProps = {
canChooseMCPTool?: boolean
onTagsChange?: Dispatch<SetStateAction<string[]>>
isInRAGPipeline?: boolean
featuredPlugins?: Plugin[]
featuredLoading?: boolean
showFeatured?: boolean
onFeaturedInstallSuccess?: () => Promise<void> | void
}
const DEFAULT_TAGS: AllToolsProps['tags'] = []
@ -67,6 +73,10 @@ const AllTools = ({
canChooseMCPTool,
onTagsChange,
isInRAGPipeline = false,
featuredPlugins = [],
featuredLoading = false,
showFeatured = false,
onFeaturedInstallSuccess,
}: AllToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
@ -80,6 +90,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<string, ToolWithProvider>()
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 +156,7 @@ const AllTools = ({
} = useMarketplacePlugins()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
useEffect(() => {
if (!enable_marketplace) return
if (hasFilter) {
@ -155,6 +176,12 @@ const AllTools = ({
const hasToolsContent = tools.length > 0
const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0
const shouldShowEmptyState = hasFilter && !hasToolsContent && !hasPluginContent
const shouldShowFeatured = showFeatured
&& enable_marketplace
&& !isInRAGPipeline
&& activeTab === ToolTypeEnum.All
&& !hasFilter
&& (featuredLoading || featuredPlugins.length > 0)
return (
<div className={cn('min-w-[400px] max-w-[500px]', className)}>
@ -193,6 +220,19 @@ const AllTools = ({
onTagsChange={onTagsChange}
/>
)}
{shouldShowFeatured && (
<FeaturedTools
plugins={featuredPlugins}
providerMap={providerMap}
onSelect={onSelect}
selectedTools={selectedTools}
canChooseMCPTool={canChooseMCPTool}
isLoading={featuredLoading}
onInstallSuccess={async () => {
await onFeaturedInstallSuccess?.()
}}
/>
)}
<Tools
className={toolContentClassName}
tools={tools}

View File

@ -92,10 +92,10 @@ const DataSources = ({
}, [searchText, enable_marketplace])
return (
<div className={cn('min-w-[400px] max-w-[500px]', className)}>
<div className={cn('w-[400px] min-w-0 max-w-full', className)}>
<div
ref={wrapElemRef}
className='max-h-[464px] overflow-y-auto'
className='max-h-[464px] overflow-y-auto overflow-x-hidden'
onScroll={pluginRef.current?.handleScroll}
>
<Tools

View File

@ -0,0 +1,231 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BlockEnum, type ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { useGetLanguage } from '@/context/i18n'
import Button from '@/app/components/base/button'
import BlockIcon from '../block-icon'
import { RiArrowDownSLine, RiArrowRightSLine, RiLoader2Line, RiMoreLine } from '@remixicon/react'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import Loading from '@/app/components/base/loading'
import Link from 'next/link'
import { getMarketplaceUrl } from '@/utils/var'
import { ToolTypeEnum } from './types'
import { ViewType } from './view-type-select'
import Tools from './tools'
const MAX_RECOMMENDED_COUNT = 15
const INITIAL_VISIBLE_COUNT = 5
type FeaturedToolsProps = {
plugins: Plugin[]
providerMap: Map<string, ToolWithProvider>
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
selectedTools?: ToolValue[]
canChooseMCPTool?: boolean
isLoading?: boolean
onInstallSuccess?: () => void
}
const STORAGE_KEY = 'workflow_tools_featured_collapsed'
const FeaturedTools = ({
plugins,
providerMap,
onSelect,
selectedTools,
canChooseMCPTool,
isLoading = false,
onInstallSuccess,
}: FeaturedToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [installingIdentifier, setInstallingIdentifier] = useState<string | null>(null)
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
const installMutation = useInstallPackageFromMarketPlace({
onSuccess: () => {
onInstallSuccess?.()
},
onSettled: () => {
setInstallingIdentifier(null)
},
})
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 visiblePlugins = useMemo(
() => plugins.slice(0, Math.min(MAX_RECOMMENDED_COUNT, visibleCount)),
[plugins, visibleCount],
)
const installedProviders = useMemo(
() =>
visiblePlugins
.map(plugin => providerMap.get(plugin.plugin_id))
.filter((provider): provider is ToolWithProvider => Boolean(provider)),
[visiblePlugins, providerMap],
)
const uninstalledPlugins = useMemo(
() => visiblePlugins.filter(plugin => !providerMap.has(plugin.plugin_id)),
[visiblePlugins, providerMap],
)
const showMore = visibleCount < Math.min(MAX_RECOMMENDED_COUNT, plugins.length)
const isMutating = installMutation.isPending
const showEmptyState = !isLoading && visiblePlugins.length === 0
return (
<div className='px-3 pb-3 pt-2'>
<button
type='button'
className='flex w-full items-center justify-between rounded-md px-0 py-1 text-left text-text-tertiary'
onClick={() => setIsCollapsed(prev => !prev)}
>
<span className='system-xs-medium'>{t('workflow.tabs.featuredTools')}</span>
{isCollapsed ? <RiArrowRightSLine className='size-3.5' /> : <RiArrowDownSLine className='size-3.5' />}
</button>
{!isCollapsed && (
<>
{isLoading && (
<div className='py-3'>
<Loading type='app' />
</div>
)}
{showEmptyState && (
<p className='system-xs-regular py-2 text-text-tertiary'>
<Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'tool' })} target='_blank' rel='noopener noreferrer'>
{t('workflow.tabs.noFeaturedPlugins')}
</Link>
</p>
)}
{!showEmptyState && !isLoading && (
<>
{installedProviders.length > 0 && (
<Tools
className='p-0'
tools={installedProviders}
onSelect={onSelect}
canNotSelectMultiple
toolType={ToolTypeEnum.All}
viewType={ViewType.flat}
hasSearchText={false}
selectedTools={selectedTools}
canChooseMCPTool={canChooseMCPTool}
/>
)}
{uninstalledPlugins.length > 0 && (
<div className='mt-1 flex flex-col gap-1'>
{uninstalledPlugins.map(plugin => (
<FeaturedToolUninstalledItem
key={plugin.plugin_id}
plugin={plugin}
language={language}
installing={isMutating && installingIdentifier === plugin.latest_package_identifier}
onInstall={() => {
if (isMutating)
return
setInstallingIdentifier(plugin.latest_package_identifier)
installMutation.mutate(plugin.latest_package_identifier)
}}
t={t}
/>
))}
</div>
)}
</>
)}
{!isLoading && visiblePlugins.length > 0 && showMore && (
<div
className='mt-1 flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2 text-text-tertiary hover:text-text-secondary'
onClick={() => {
setVisibleCount(count => Math.min(count + INITIAL_VISIBLE_COUNT, MAX_RECOMMENDED_COUNT, plugins.length))
}}
>
<div className='px-1'>
<RiMoreLine className='size-4' />
</div>
<div className='system-xs-regular'>
{t('common.operation.more')}
</div>
</div>
)}
</>
)}
</div>
)
}
type FeaturedToolUninstalledItemProps = {
plugin: Plugin
language: string
installing: boolean
onInstall: () => void
t: (key: string, options?: Record<string, any>) => string
}
function FeaturedToolUninstalledItem({
plugin,
language,
installing,
onInstall,
t,
}: FeaturedToolUninstalledItemProps) {
const label = plugin.label?.[language] || plugin.name
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
const installCountLabel = t('plugin.install', { num: plugin.install_count?.toLocaleString() ?? 0 })
return (
<div className='group flex items-center justify-between rounded-lg px-3 py-2 hover:bg-state-base-hover'>
<div className='flex min-w-0 items-center gap-3'>
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className='min-w-0'>
<div className='system-sm-medium truncate text-text-primary'>{label}</div>
{description && (
<div className='system-xs-regular truncate text-text-tertiary'>{description}</div>
)}
</div>
</div>
<div className='ml-3 flex items-center'>
<span className='system-xs-regular text-text-tertiary group-hover:hidden'>{installCountLabel}</span>
<Button
size='small'
variant='secondary'
className='hidden items-center gap-1 group-hover:flex'
disabled={installing}
onClick={onInstall}
>
{installing ? t('workflow.nodes.agent.pluginInstaller.installing') : t('workflow.nodes.agent.pluginInstaller.install')}
{installing && <RiLoader2Line className='size-3 animate-spin' />}
</Button>
</div>
</div>
)
}
export default FeaturedTools

View File

@ -1,6 +1,6 @@
import type { Dispatch, FC, SetStateAction } from 'react'
import { memo } from 'react'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
import type {
BlockEnum,
NodeDefault,
@ -13,6 +13,8 @@ import AllStartBlocks from './all-start-blocks'
import AllTools from './all-tools'
import DataSources from './data-sources'
import cn from '@/utils/classnames'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import { useGlobalPublicStore } from '@/context/global-public-context'
export type TabsProps = {
activeTab: TabsEnum
@ -53,6 +55,13 @@ const Tabs: FC<TabsProps> = ({
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const inRAGPipeline = dataSources.length > 0
const {
plugins: featuredPlugins = [],
isLoading: isFeaturedLoading,
} = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
return (
<div onClick={e => e.stopPropagation()}>
@ -127,7 +136,13 @@ const Tabs: FC<TabsProps> = ({
mcpTools={mcpTools || []}
canChooseMCPTool
onTagsChange={onTagsChange}
isInRAGPipeline={dataSources.length > 0}
isInRAGPipeline={inRAGPipeline}
featuredPlugins={featuredPlugins}
featuredLoading={isFeaturedLoading}
showFeatured={enable_marketplace && !inRAGPipeline}
onFeaturedInstallSuccess={async () => {
invalidateBuiltInTools()
}}
/>
)
}

View File

@ -23,7 +23,9 @@ import {
} from '@/service/tools'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import Toast from '@/app/components/base/toast'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools, useInvalidateAllCustomTools } from '@/service/use-tools'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import { useGlobalPublicStore } from '@/context/global-public-context'
import cn from '@/utils/classnames'
type Props = {
@ -61,11 +63,18 @@ const ToolPicker: FC<Props> = ({
const [searchText, setSearchText] = useState('')
const [tags, setTags] = useState<string[]>([])
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const invalidateCustomTools = useInvalidateAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
const {
plugins: featuredPlugins = [],
isLoading: isFeaturedLoading,
} = useFeaturedToolsRecommendations(enable_marketplace)
const { builtinToolList, customToolList, workflowToolList } = useMemo(() => {
if (scope === 'plugins') {
@ -179,6 +188,12 @@ const ToolPicker: FC<Props> = ({
selectedTools={selectedTools}
canChooseMCPTool={canChooseMCPTool}
onTagsChange={setTags}
featuredPlugins={featuredPlugins}
featuredLoading={isFeaturedLoading}
showFeatured={scope === 'all' && enable_marketplace}
onFeaturedInstallSuccess={async () => {
invalidateBuiltInTools()
}}
/>
</div>
</PortalToFollowElemContent>

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Benutzereingabefeld',
helpLink: 'Hilfelink',
helpLink: 'Hilfe',
about: 'Über',
createdBy: 'Erstellt von ',
nextStep: 'Nächster Schritt',

View File

@ -181,7 +181,7 @@ const translation = {
emailSupport: 'Email Support',
workspace: 'Workspace',
createWorkspace: 'Create Workspace',
helpCenter: 'Docs',
helpCenter: 'View Docs',
support: 'Support',
compliance: 'Compliance',
communityFeedback: 'Feedback',

View File

@ -277,6 +277,13 @@ 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',
'noFeaturedPlugins': 'Discover more tools in Marketplace',
},
blocks: {
'start': 'User Input',
@ -366,7 +373,7 @@ const translation = {
panel: {
userInputField: 'User Input Field',
changeBlock: 'Change Node',
helpLink: 'Help Link',
helpLink: 'View Docs',
about: 'About',
createdBy: 'Created By ',
nextStep: 'Next Step',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Campo de entrada del usuario',
helpLink: 'Enlace de ayuda',
helpLink: 'Ayuda',
about: 'Acerca de',
createdBy: 'Creado por ',
nextStep: 'Siguiente paso',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'فیلد ورودی کاربر',
helpLink: 'لینک کمک',
helpLink: 'راهنما',
about: 'درباره',
createdBy: 'ساخته شده توسط',
nextStep: 'مرحله بعدی',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Champ de saisie de l\'utilisateur',
helpLink: 'Lien d\'aide',
helpLink: 'Aide',
about: 'À propos',
createdBy: 'Créé par',
nextStep: 'Étape suivante',

View File

@ -320,7 +320,7 @@ const translation = {
},
panel: {
userInputField: 'उपयोगकर्ता इनपुट फ़ील्ड',
helpLink: 'सहायता लिंक',
helpLink: 'सहायता',
about: 'के बारे में',
createdBy: 'द्वारा बनाया गया ',
nextStep: 'अगला कदम',

View File

@ -321,7 +321,7 @@ const translation = {
userInputField: 'Bidang Input Pengguna',
checklistResolved: 'Semua masalah terselesaikan',
createdBy: 'Dibuat oleh',
helpLink: 'Tautan Bantuan',
helpLink: 'Docs',
changeBlock: 'Ubah Node',
runThisStep: 'Jalankan langkah ini',
maximize: 'Maksimalkan Kanvas',

View File

@ -323,7 +323,7 @@ const translation = {
},
panel: {
userInputField: 'Campo di Input Utente',
helpLink: 'Link di Aiuto',
helpLink: 'Aiuto',
about: 'Informazioni',
createdBy: 'Creato da ',
nextStep: 'Prossimo Passo',

View File

@ -171,7 +171,7 @@ const translation = {
emailSupport: 'サポート',
workspace: 'ワークスペース',
createWorkspace: 'ワークスペースを作成',
helpCenter: 'ヘルプ',
helpCenter: 'ドキュメントを見る',
support: 'サポート',
compliance: 'コンプライアンス',
communityFeedback: 'フィードバック',

View File

@ -349,7 +349,7 @@ const translation = {
panel: {
userInputField: 'ユーザー入力欄',
changeBlock: 'ノード変更',
helpLink: 'ヘルプリンク',
helpLink: 'ドキュメントを見る',
about: '詳細',
createdBy: '作成者',
nextStep: '次のステップ',

View File

@ -330,7 +330,7 @@ const translation = {
},
panel: {
userInputField: '사용자 입력 필드',
helpLink: '도움말 링크',
helpLink: '도움말 센터',
about: '정보',
createdBy: '작성자 ',
nextStep: '다음 단계',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Pole wprowadzania użytkownika',
helpLink: 'Link do pomocy',
helpLink: 'Pomoc',
about: 'O',
createdBy: 'Stworzone przez ',
nextStep: 'Następny krok',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Campo de entrada do usuário',
helpLink: 'Link de ajuda',
helpLink: 'Ajuda',
about: 'Sobre',
createdBy: 'Criado por ',
nextStep: 'Próximo passo',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Câmp de introducere utilizator',
helpLink: 'Link de ajutor',
helpLink: 'Ajutor',
about: 'Despre',
createdBy: 'Creat de ',
nextStep: 'Pasul următor',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Поле ввода пользователя',
helpLink: 'Ссылка на справку',
helpLink: 'Помощь',
about: 'О программе',
createdBy: 'Создано ',
nextStep: 'Следующий шаг',

View File

@ -324,7 +324,7 @@ const translation = {
addNextStep: 'Dodajte naslednji korak v ta delovni potek',
checklistTip: 'Prepričajte se, da so vse težave rešene, preden objavite.',
selectNextStep: 'Izberi naslednji korak',
helpLink: 'Pomočna povezava',
helpLink: 'Pomoč',
checklist: 'Kontrolni seznam',
checklistResolved: 'Vse težave so rešene',
createdBy: 'Ustvarjeno z',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'ฟิลด์ป้อนข้อมูลของผู้ใช้',
helpLink: 'ลิงค์ช่วยเหลือ',
helpLink: 'วิธีใช้',
about: 'ประมาณ',
createdBy: 'สร้างโดย',
nextStep: 'ขั้นตอนถัดไป',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Kullanıcı Giriş Alanı',
helpLink: 'Yardım Linki',
helpLink: 'Yardım',
about: 'Hakkında',
createdBy: 'Oluşturan: ',
nextStep: 'Sonraki Adım',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Поле введення користувача',
helpLink: 'Посилання на допомогу',
helpLink: 'Довідковий центр',
about: 'Про',
createdBy: 'Створено ',
nextStep: 'Наступний крок',

View File

@ -309,7 +309,7 @@ const translation = {
},
panel: {
userInputField: 'Trường đầu vào của người dùng',
helpLink: 'Liên kết trợ giúp',
helpLink: 'Trung tâm trợ giúp',
about: 'Giới thiệu',
createdBy: 'Tạo bởi ',
nextStep: 'Bước tiếp theo',

View File

@ -181,7 +181,7 @@ const translation = {
emailSupport: '邮件支持',
workspace: '工作空间',
createWorkspace: '创建工作空间',
helpCenter: '帮助文档',
helpCenter: '查看帮助文档',
support: '支持',
compliance: '合规',
communityFeedback: '用户反馈',

View File

@ -263,6 +263,13 @@ const translation = {
'sources': '数据源',
'searchDataSource': '搜索数据源',
'start': '开始',
'featuredTools': '精选推荐',
'showMoreFeatured': '查看更多',
'installed': '已安装',
'pluginByAuthor': '来自 {{author}}',
'usePlugin': '选择工具',
'hideActions': '收起工具',
'noFeaturedPlugins': '前往插件市场查看更多工具',
},
blocks: {
'start': '用户输入',
@ -352,7 +359,7 @@ const translation = {
panel: {
userInputField: '用户输入字段',
changeBlock: '更改节点',
helpLink: '帮助链接',
helpLink: '查看帮助文档',
about: '关于',
createdBy: '作者',
nextStep: '下一步',

View File

@ -160,7 +160,7 @@ const translation = {
emailSupport: '電子郵件支援',
workspace: '工作空間',
createWorkspace: '建立工作空間',
helpCenter: '幫助文件',
helpCenter: '查看幫助文件',
communityFeedback: '使用者反饋',
roadmap: '路線圖',
community: '社群',

View File

@ -310,7 +310,7 @@ const translation = {
panel: {
userInputField: '用戶輸入字段',
changeBlock: '更改節點',
helpLink: '幫助鏈接',
helpLink: '查看幫助文件',
about: '關於',
createdBy: '作者',
nextStep: '下一步',

View File

@ -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,50 @@ 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<Plugin[]>({
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 useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => {
const {
data: plugins = [],
isLoading,
} = useRecommendedMarketplacePlugins({
enabled,
limit,
})
return {
plugins,
isLoading,
}
}
export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => {
const fetchPlugins = async ({ pageParam = 1 }) => {
const response = await get<InstalledPluginListWithTotalResponse>(