diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 32cd739849..64ce869c5d 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -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 = ({ {!isAppAccessSet &&

{t('app.publishApp.notSetDesc')}

} } -
- - } - > - {t('workflow.common.runApp')} - - - {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION - ? ( + { + // Hide run/batch run app buttons when there is a trigger node. + !hasTriggerNode && ( +
} + link={appURL} + icon={} > - {t('workflow.common.batchRunApp')} + {t('workflow.common.runApp')} - ) - : ( - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('workflow.common.embedIntoSite')} - - )} - - { - if (publishedAt) - handleOpenInExplore() - }} - disabled={disabledFunctionButton} - icon={} - > - {t('workflow.common.openInExplore')} - - - - } - > - {t('workflow.common.accessAPIReference')} - - - {appDetail?.mode === AppModeEnum.WORKFLOW && ( - + {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION + ? ( + + } + > + {t('workflow.common.batchRunApp')} + + + ) + : ( + { + setEmbeddingModalOpen(true) + handleTrigger() + }} + disabled={!publishedAt} + icon={} + > + {t('workflow.common.embedIntoSite')} + + )} + + { + if (publishedAt) + handleOpenInExplore() + }} + disabled={disabledFunctionButton} + icon={} + > + {t('workflow.common.openInExplore')} + + + + } + > + {t('workflow.common.accessAPIReference')} + + + {appDetail?.mode === AppModeEnum.WORKFLOW && ( + + )} +
)} -
} diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 444d2e7b01..c81f9aacbf 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -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, }} /> 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 892501301e..a089978bdd 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -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} /> diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index d595f8fd09..e2dd14e16c 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -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, diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 661c2072a0..5f8480d9f5 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -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 = ({ open: openFromProps, @@ -76,11 +87,44 @@ const NodeSelector: FC = ({ showStartTab = false, defaultActiveTab, forceShowStartContent = false, + ignoreNodeIds = [], + forceEnableStartTab = false, + allowUserInputSelection, }) => { const { t } = useTranslation() + const nodes = useNodes() const [searchText, setSearchText] = useState('') const [tags, setTags] = useState([]) 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 = ({ 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 = ({ : (
= ({ tabs={tabs} activeTab={activeTab} blocks={blocks} + allowStartNodeSelection={canSelectUserInput} onActiveTabChange={handleActiveTabChange} filterElem={
e.stopPropagation()}> diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index 4fdfd475d9..31b6abce6c 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -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 diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index a1b2839b45..ecdb8797c0 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -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 = ({ activeTab, @@ -52,7 +56,9 @@ const Tabs: FC = ({ 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 = ({ !noBlocks && (
{ - tabs.map(tab => ( -
onActiveTabChange(tab.key)} - > - {tab.name} -
- )) + 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 ( + +
+ {tab.name} +
+
+ ) + } + return ( +
+ {tab.name} +
+ ) + }) }
) @@ -148,6 +180,7 @@ const Tabs: FC = ({ activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
{ const { t } = useTranslation() const { handleNodeChange } = useNodesInteractions() + const nodes = useNodes() 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} /> ) } diff --git a/web/app/components/workflow/nodes/start/default.ts b/web/app/components/workflow/nodes/start/default.ts index 9780549c5a..60584b5144 100644 --- a/web/app/components/workflow/nodes/start/default.ts +++ b/web/app/components/workflow/nodes/start/default.ts @@ -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 = { diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 77e71b0973..92a0b110c7 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -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', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index d55f12fc8b..07241b8c4f 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -276,6 +276,7 @@ const translation = { 'searchDataSource': 'データソースを検索', 'sources': 'ソース', 'start': '始める', + 'startDisabledTip': 'トリガーノードとユーザー入力ノードは互いに排他です。', }, blocks: { 'start': 'ユーザー入力', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 53ec625160..18e76caa64 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -286,6 +286,7 @@ const translation = { 'hideActions': '收起工具', 'noFeaturedPlugins': '前往插件市场查看更多工具', 'noFeaturedTriggers': '前往插件市场查看更多触发器', + 'startDisabledTip': '触发节点与用户输入节点互斥。', }, blocks: { 'start': '用户输入',