mirror of
https://github.com/langgenius/dify.git
synced 2026-01-08 07:14:14 +00:00
feat(trigger): add support for trigger nodes and user input node management in workflow components
This commit is contained in:
@@ -105,6 +105,7 @@ export type AppPublisherProps = {
|
||||
onRefreshData?: () => void
|
||||
workflowToolAvailable?: boolean
|
||||
missingStartNode?: boolean
|
||||
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
@@ -125,6 +126,7 @@ const AppPublisher = ({
|
||||
onRefreshData,
|
||||
workflowToolAvailable = true,
|
||||
missingStartNode = false,
|
||||
hasTriggerNode = false,
|
||||
}: AppPublisherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -350,84 +352,88 @@ const AppPublisher = ({
|
||||
</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular mt-1 text-text-warning'>{t('app.publishApp.notSetDesc')}</p>}
|
||||
</div>}
|
||||
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.runApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
{
|
||||
// Hide run/batch run app buttons when there is a trigger node.
|
||||
!hasTriggerNode && (
|
||||
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||
link={appURL}
|
||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.batchRunApp')}
|
||||
{t('workflow.common.runApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.embedIntoSite')}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.openInExplore')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<Tooltip triggerClassName='flex' disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('app.notPublishedYet') : t('app.noUserInputNode')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link='./develop'
|
||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.accessAPIReference')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.batchRunApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.embedIntoSite')}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.openInExplore')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<Tooltip triggerClassName='flex' disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('app.notPublishedYet') : t('app.noUserInputNode')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link='./develop'
|
||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.accessAPIReference')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
@@ -40,6 +40,12 @@ import { useIsChatMode } from '../../hooks'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
|
||||
const TRIGGER_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
const FeaturesTrigger = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
@@ -89,6 +95,10 @@ const FeaturesTrigger = () => {
|
||||
return false
|
||||
return edges.some(edge => startNodeIds.includes(edge.source))
|
||||
}, [edges, startNodeIds])
|
||||
// Track trigger presence so the publisher can adjust UI (e.g. hide missing start section).
|
||||
const hasTriggerNode = useMemo(() => (
|
||||
nodes.some(node => TRIGGER_NODE_TYPES.includes(node.data.type as BlockEnum))
|
||||
), [nodes])
|
||||
|
||||
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
|
||||
const invalidateAppTriggers = useInvalidateAppTriggers()
|
||||
@@ -189,6 +199,7 @@ const FeaturesTrigger = () => {
|
||||
workflowToolAvailable: lastPublishedHasUserInput,
|
||||
crossAxisOffset: 4,
|
||||
missingStartNode: !startNode,
|
||||
hasTriggerNode,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -32,6 +32,7 @@ type AllStartBlocksProps = {
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
tags?: string[]
|
||||
allowUserInputSelection?: boolean // Allow user input option even when trigger node already exists (e.g. when no Start node yet or changing node type).
|
||||
}
|
||||
|
||||
const AllStartBlocks = ({
|
||||
@@ -40,6 +41,7 @@ const AllStartBlocks = ({
|
||||
onSelect,
|
||||
availableBlocksTypes,
|
||||
tags = [],
|
||||
allowUserInputSelection = false,
|
||||
}: AllStartBlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false)
|
||||
@@ -122,6 +124,7 @@ const AllStartBlocks = ({
|
||||
searchText={trimmedSearchText}
|
||||
onSelect={onSelect as OnSelectBlock}
|
||||
availableBlocksTypes={entryNodeTypes as unknown as BlockEnum[]}
|
||||
hideUserInput={!allowUserInputSelection}
|
||||
onContentStateChange={handleStartBlocksContentChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -31,16 +33,28 @@ export const useStartBlocks = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useTabs = ({ noBlocks, noSources, noTools, noStart = true, defaultActiveTab }: {
|
||||
export const useTabs = ({
|
||||
noBlocks,
|
||||
noSources,
|
||||
noTools,
|
||||
noStart = true,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode = false,
|
||||
forceEnableStartTab = false, // When true, Start tab remains enabled even if trigger/user input nodes already exist.
|
||||
}: {
|
||||
noBlocks?: boolean
|
||||
noSources?: boolean
|
||||
noTools?: boolean
|
||||
noStart?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
hasUserInputNode?: boolean
|
||||
forceEnableStartTab?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const shouldShowStartTab = !noStart
|
||||
const shouldDisableStartTab = !forceEnableStartTab && hasUserInputNode
|
||||
const tabs = useMemo(() => {
|
||||
return [{
|
||||
const tabConfigs = [{
|
||||
key: TabsEnum.Blocks,
|
||||
name: t('workflow.tabs.blocks'),
|
||||
show: !noBlocks,
|
||||
@@ -56,25 +70,54 @@ export const useTabs = ({ noBlocks, noSources, noTools, noStart = true, defaultA
|
||||
{
|
||||
key: TabsEnum.Start,
|
||||
name: t('workflow.tabs.start'),
|
||||
show: !noStart,
|
||||
}].filter(tab => tab.show)
|
||||
}, [t, noBlocks, noSources, noTools, noStart])
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
}]
|
||||
|
||||
return tabConfigs.filter(tab => tab.show)
|
||||
}, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab])
|
||||
|
||||
const getValidTabKey = useCallback((targetKey?: TabsEnum) => {
|
||||
if (!targetKey)
|
||||
return undefined
|
||||
const tab = tabs.find(tabItem => tabItem.key === targetKey)
|
||||
if (!tab || tab.disabled)
|
||||
return undefined
|
||||
return tab.key
|
||||
}, [tabs])
|
||||
|
||||
const initialTab = useMemo(() => {
|
||||
// If a default tab is specified, use it
|
||||
if (defaultActiveTab)
|
||||
return defaultActiveTab
|
||||
const fallbackTab = tabs.find(tab => !tab.disabled)?.key ?? TabsEnum.Blocks
|
||||
const preferredDefault = getValidTabKey(defaultActiveTab)
|
||||
if (preferredDefault)
|
||||
return preferredDefault
|
||||
|
||||
if (noBlocks)
|
||||
return noTools ? TabsEnum.Sources : TabsEnum.Tools
|
||||
const preferredOrder: TabsEnum[] = []
|
||||
if (!noBlocks)
|
||||
preferredOrder.push(TabsEnum.Blocks)
|
||||
if (!noTools)
|
||||
preferredOrder.push(TabsEnum.Tools)
|
||||
if (!noSources)
|
||||
preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart)
|
||||
preferredOrder.push(TabsEnum.Start)
|
||||
|
||||
if (noTools)
|
||||
return noBlocks ? TabsEnum.Sources : TabsEnum.Blocks
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
if (validKey)
|
||||
return validKey
|
||||
}
|
||||
|
||||
return TabsEnum.Blocks
|
||||
}, [noBlocks, noSources, noTools, defaultActiveTab])
|
||||
return fallbackTab
|
||||
}, [defaultActiveTab, noBlocks, noSources, noTools, noStart, tabs, getValidTabKey])
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
useEffect(() => {
|
||||
const currentTab = tabs.find(tab => tab.key === activeTab)
|
||||
if (!currentTab || currentTab.disabled)
|
||||
setActiveTab(initialTab)
|
||||
}, [tabs, activeTab, initialTab])
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
|
||||
@@ -9,16 +9,18 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodes } from 'reactflow'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import type {
|
||||
BlockEnum,
|
||||
CommonNodeType,
|
||||
NodeDefault,
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
import { useTabs } from './hooks'
|
||||
@@ -33,6 +35,12 @@ import {
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
|
||||
const TRIGGER_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
export type NodeSelectorProps = {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
@@ -54,6 +62,9 @@ export type NodeSelectorProps = {
|
||||
showStartTab?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
forceShowStartContent?: boolean
|
||||
ignoreNodeIds?: string[]
|
||||
forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
|
||||
allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist.
|
||||
}
|
||||
const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
open: openFromProps,
|
||||
@@ -76,11 +87,44 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
showStartTab = false,
|
||||
defaultActiveTab,
|
||||
forceShowStartContent = false,
|
||||
ignoreNodeIds = [],
|
||||
forceEnableStartTab = false,
|
||||
allowUserInputSelection,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!ignoreNodeIds.length)
|
||||
return nodes
|
||||
const ignoreSet = new Set(ignoreNodeIds)
|
||||
return nodes.filter(node => !ignoreSet.has(node.id))
|
||||
}, [nodes, ignoreNodeIds])
|
||||
|
||||
const { hasTriggerNode, hasUserInputNode } = useMemo(() => {
|
||||
const result = {
|
||||
hasTriggerNode: false,
|
||||
hasUserInputNode: false,
|
||||
}
|
||||
for (const node of filteredNodes) {
|
||||
const nodeType = (node.data as CommonNodeType | undefined)?.type
|
||||
if (!nodeType)
|
||||
continue
|
||||
if (nodeType === BlockEnum.Start)
|
||||
result.hasUserInputNode = true
|
||||
if (TRIGGER_NODE_TYPES.includes(nodeType))
|
||||
result.hasTriggerNode = true
|
||||
if (result.hasTriggerNode && result.hasUserInputNode)
|
||||
break
|
||||
}
|
||||
return result
|
||||
}, [filteredNodes])
|
||||
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
@@ -107,7 +151,15 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
} = useTabs({ noBlocks, noSources: !dataSources.length, noTools, noStart: !showStartTab, defaultActiveTab })
|
||||
} = useTabs({
|
||||
noBlocks,
|
||||
noSources: !dataSources.length,
|
||||
noTools,
|
||||
noStart: !showStartTab,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
@@ -146,7 +198,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
@@ -163,6 +215,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
blocks={blocks}
|
||||
allowStartNodeSelection={canSelectUserInput}
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
filterElem={
|
||||
<div className='relative m-2' onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -20,6 +20,7 @@ type StartBlocksProps = {
|
||||
onSelect: (type: BlockEnum, triggerDefaultValue?: TriggerDefaultValue) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
onContentStateChange?: (hasContent: boolean) => void
|
||||
hideUserInput?: boolean
|
||||
}
|
||||
|
||||
const StartBlocks = ({
|
||||
@@ -27,6 +28,7 @@ const StartBlocks = ({
|
||||
onSelect,
|
||||
availableBlocksTypes = [],
|
||||
onContentStateChange,
|
||||
hideUserInput = false, // Allow parent to explicitly hide Start node option (e.g. when one already exists).
|
||||
}: StartBlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
@@ -45,8 +47,8 @@ const StartBlocks = ({
|
||||
}
|
||||
|
||||
return START_BLOCKS.filter((block) => {
|
||||
// Hide User Input (Start) if it already exists in workflow
|
||||
if (block.type === BlockEnumValues.Start && hasStartNode)
|
||||
// Hide User Input (Start) if it already exists in workflow or if hideUserInput is true
|
||||
if (block.type === BlockEnumValues.Start && (hasStartNode || hideUserInput))
|
||||
return false
|
||||
|
||||
// Filter by search text
|
||||
@@ -57,7 +59,7 @@ const StartBlocks = ({
|
||||
// availableBlocksTypes now contains properly filtered entry node types from parent
|
||||
return availableBlocksTypes.includes(block.type)
|
||||
})
|
||||
}, [searchText, availableBlocksTypes, nodes, t])
|
||||
}, [searchText, availableBlocksTypes, nodes, t, hideUserInput])
|
||||
|
||||
const isEmpty = filteredBlocks.length === 0
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
|
||||
import type {
|
||||
BlockEnum,
|
||||
@@ -17,6 +18,7 @@ import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { basePath } from '@/utils/var'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
export type TabsProps = {
|
||||
activeTab: TabsEnum
|
||||
@@ -31,11 +33,13 @@ export type TabsProps = {
|
||||
tabs: Array<{
|
||||
key: TabsEnum
|
||||
name: string
|
||||
disabled?: boolean
|
||||
}>
|
||||
filterElem: React.ReactNode
|
||||
noBlocks?: boolean
|
||||
noTools?: boolean
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
}
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
@@ -52,7 +56,9 @@ const Tabs: FC<TabsProps> = ({
|
||||
noBlocks,
|
||||
noTools,
|
||||
forceShowStartContent = false,
|
||||
allowStartNodeSelection = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
@@ -125,20 +131,46 @@ const Tabs: FC<TabsProps> = ({
|
||||
!noBlocks && (
|
||||
<div className='relative flex bg-background-section-burn pl-1 pt-1'>
|
||||
{
|
||||
tabs.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 cursor-pointer items-center rounded-t-lg px-3 ',
|
||||
activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
onClick={() => onActiveTabChange(tab.key)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
))
|
||||
tabs.map((tab) => {
|
||||
const commonProps = {
|
||||
'className': cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
),
|
||||
'aria-disabled': tab.disabled,
|
||||
'onClick': () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
},
|
||||
} as const
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position='top'
|
||||
popupClassName='max-w-[200px]'
|
||||
popupContent={t('workflow.tabs.startDisabledTip')}
|
||||
>
|
||||
<div {...commonProps}>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
{...commonProps}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
@@ -148,6 +180,7 @@ const Tabs: FC<TabsProps> = ({
|
||||
activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
<AllStartBlocks
|
||||
allowUserInputSelection={allowStartNodeSelection}
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { intersection } from 'lodash-es'
|
||||
import { useNodes } from 'reactflow'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
@@ -13,9 +14,17 @@ import {
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
Node,
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const TRIGGER_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
type ChangeBlockProps = {
|
||||
@@ -30,6 +39,7 @@ const ChangeBlock = ({
|
||||
}: ChangeBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleNodeChange } = useNodesInteractions()
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
const {
|
||||
availablePrevBlocks,
|
||||
availableNextBlocks,
|
||||
@@ -37,6 +47,30 @@ const ChangeBlock = ({
|
||||
const isChatMode = useIsChatMode()
|
||||
const flowType = useHooksStore(s => s.configsMap?.flowType)
|
||||
const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode
|
||||
// Count total trigger nodes
|
||||
const totalTriggerNodes = useMemo(() => (
|
||||
nodes.filter(node => TRIGGER_NODE_TYPES.includes(node.data.type as BlockEnum)).length
|
||||
), [nodes])
|
||||
// Check if there is a User Input node
|
||||
const hasUserInputNode = useMemo(() => (
|
||||
nodes.some(node => node.data.type === BlockEnum.Start)
|
||||
), [nodes])
|
||||
// Check if the current node is a trigger node
|
||||
const isTriggerNode = TRIGGER_NODE_TYPES.includes(nodeData.type as BlockEnum)
|
||||
// Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
|
||||
const forceEnableStartTab = isTriggerNode || nodeData.type === BlockEnum.Start
|
||||
// Only allow converting a trigger into User Input when it's the sole trigger and no User Input exists yet.
|
||||
const canChangeTriggerToUserInput = isTriggerNode && !hasUserInputNode && totalTriggerNodes === 1
|
||||
// Ignore current node when it's a trigger so the Start tab logic doesn't treat it as existing trigger.
|
||||
const ignoreNodeIds = useMemo(() => {
|
||||
if (TRIGGER_NODE_TYPES.includes(nodeData.type as BlockEnum))
|
||||
return [nodeId]
|
||||
return undefined
|
||||
}, [nodeData.type, nodeId])
|
||||
// Determine user input selection based on node type and trigger/user input node presence.
|
||||
const allowUserInputSelection = forceEnableStartTab
|
||||
? (nodeData.type === BlockEnum.Start ? false : canChangeTriggerToUserInput)
|
||||
: undefined
|
||||
|
||||
const availableNodes = useMemo(() => {
|
||||
if (availablePrevBlocks.length && availableNextBlocks.length)
|
||||
@@ -71,6 +105,10 @@ const ChangeBlock = ({
|
||||
popupClassName='min-w-[240px]'
|
||||
availableBlocksTypes={availableNodes}
|
||||
showStartTab={showStartTab}
|
||||
ignoreNodeIds={ignoreNodeIds}
|
||||
// When changing Start/Trigger nodes, force-enable Start tab to allow switching among entry nodes.
|
||||
forceEnableStartTab={forceEnableStartTab}
|
||||
allowUserInputSelection={allowUserInputSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const metaData = genNodeMetaData({
|
||||
isStart: true,
|
||||
isRequired: false,
|
||||
isSingleton: true,
|
||||
isTypeFixed: true,
|
||||
isTypeFixed: false, // support node type change for start node(user input)
|
||||
helpLinkUri: 'user-input',
|
||||
})
|
||||
const nodeDefault: NodeDefault<StartNodeType> = {
|
||||
|
||||
@@ -287,6 +287,7 @@ const translation = {
|
||||
'hideActions': 'Hide tools',
|
||||
'noFeaturedPlugins': 'Discover more tools in Marketplace',
|
||||
'noFeaturedTriggers': 'Discover more triggers in Marketplace',
|
||||
'startDisabledTip': 'Trigger node and user input node are mutually exclusive.',
|
||||
},
|
||||
blocks: {
|
||||
'start': 'User Input',
|
||||
|
||||
@@ -276,6 +276,7 @@ const translation = {
|
||||
'searchDataSource': 'データソースを検索',
|
||||
'sources': 'ソース',
|
||||
'start': '始める',
|
||||
'startDisabledTip': 'トリガーノードとユーザー入力ノードは互いに排他です。',
|
||||
},
|
||||
blocks: {
|
||||
'start': 'ユーザー入力',
|
||||
|
||||
@@ -286,6 +286,7 @@ const translation = {
|
||||
'hideActions': '收起工具',
|
||||
'noFeaturedPlugins': '前往插件市场查看更多工具',
|
||||
'noFeaturedTriggers': '前往插件市场查看更多触发器',
|
||||
'startDisabledTip': '触发节点与用户输入节点互斥。',
|
||||
},
|
||||
blocks: {
|
||||
'start': '用户输入',
|
||||
|
||||
Reference in New Issue
Block a user