feat: introduce trigger functionality (#27644)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: Stream <Stream_2@qq.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Harry <xh001x@hotmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: yessenia <yessenia.contact@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WTW0313 <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yeuoly
2025-11-12 17:59:37 +08:00
committed by GitHub
parent ca7794305b
commit b76e17b25d
785 changed files with 41186 additions and 3725 deletions

View File

@@ -1,19 +1,32 @@
import {
memo,
useCallback,
useState,
} from 'react'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { generateNewNode } from '@/app/components/workflow/utils'
import { useStore } from '@/app/components/workflow/store'
import { useStoreApi } from 'reactflow'
import PluginDependency from '../../workflow/plugin-dependency'
import {
useAutoGenerateWebhookUrl,
useDSL,
usePanelInteractions,
} from '@/app/components/workflow/hooks'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import WorkflowHeader from './workflow-header'
import WorkflowPanel from './workflow-panel'
import dynamic from 'next/dynamic'
import { BlockEnum } from '@/app/components/workflow/types'
import type {
PluginDefaultValue,
TriggerDefaultValue,
} from '@/app/components/workflow/block-selector/types'
import { useAutoOnboarding } from '../hooks/use-auto-onboarding'
import { useAvailableNodesMetaData } from '../hooks'
const Features = dynamic(() => import('@/app/components/workflow/features'), {
ssr: false,
@@ -24,6 +37,34 @@ const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-ds
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), {
ssr: false,
})
const getTriggerPluginNodeData = (
triggerConfig: TriggerDefaultValue,
fallbackTitle?: string,
fallbackDesc?: string,
) => {
return {
plugin_id: triggerConfig.plugin_id,
provider_id: triggerConfig.provider_name,
provider_type: triggerConfig.provider_type,
provider_name: triggerConfig.provider_name,
event_name: triggerConfig.event_name,
event_label: triggerConfig.event_label,
event_description: triggerConfig.event_description,
title: triggerConfig.event_label || triggerConfig.title || fallbackTitle,
desc: triggerConfig.event_description || fallbackDesc,
output_schema: { ...triggerConfig.output_schema },
parameters_schema: triggerConfig.paramSchemas ? [...triggerConfig.paramSchemas] : [],
config: { ...triggerConfig.params },
subscription_id: triggerConfig.subscription_id,
plugin_unique_identifier: triggerConfig.plugin_unique_identifier,
is_team_authorization: triggerConfig.is_team_authorization,
meta: triggerConfig.meta ? { ...triggerConfig.meta } : undefined,
}
}
const WorkflowChildren = () => {
const { eventEmitter } = useEventEmitterContextContext()
@@ -31,6 +72,14 @@ const WorkflowChildren = () => {
const showFeaturesPanel = useStore(s => s.showFeaturesPanel)
const showImportDSLModal = useStore(s => s.showImportDSLModal)
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
const showOnboarding = useStore(s => s.showOnboarding)
const setShowOnboarding = useStore(s => s.setShowOnboarding)
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
const reactFlowStore = useStoreApi()
const availableNodesMetaData = useAvailableNodesMetaData()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleOnboardingClose } = useAutoOnboarding()
const {
handlePaneContextmenuCancel,
} = usePanelInteractions()
@@ -44,12 +93,84 @@ const WorkflowChildren = () => {
setSecretEnvList(v.payload.data as EnvironmentVariable[])
})
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
const handleCloseOnboarding = useCallback(() => {
handleOnboardingClose()
}, [handleOnboardingClose])
const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType]
if (!nodeDefault?.defaultValue)
return
const baseNodeData = { ...nodeDefault.defaultValue }
const mergedNodeData = (() => {
if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) {
return {
...baseNodeData,
...toolConfig,
}
}
const triggerNodeData = getTriggerPluginNodeData(
toolConfig as TriggerDefaultValue,
baseNodeData.title,
baseNodeData.desc,
)
return {
...baseNodeData,
...triggerNodeData,
config: {
...(baseNodeData as { config?: Record<string, any> }).config,
...triggerNodeData.config,
},
}
})()
const { newNode } = generateNewNode({
data: {
...mergedNodeData,
} as any,
position: START_INITIAL_POSITION,
})
const { setNodes, setEdges } = reactFlowStore.getState()
setNodes([newNode])
setEdges([])
setShowOnboarding?.(false)
setHasSelectedStartNode?.(true)
setShouldAutoOpenStartNodeSelector?.(true)
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
autoGenerateWebhookUrl(newNode.id)
console.log('Node successfully saved to draft')
},
onError: () => {
console.error('Failed to save node to draft')
},
})
}, [availableNodesMetaData, setShowOnboarding, setHasSelectedStartNode, reactFlowStore, handleSyncWorkflowDraft])
return (
<>
<PluginDependency />
{
showFeaturesPanel && <Features />
}
{
showOnboarding && (
<WorkflowOnboardingModal
isShow={showOnboarding}
onClose={handleCloseOnboarding}
onSelectStartNode={handleSelectStartNode}
/>
)
}
{
showImportDSLModal && (
<UpdateDSLModal

View File

@@ -3,7 +3,7 @@ import {
useCallback,
useMemo,
} from 'react'
import { useEdges, useNodes, useStore as useReactflowStore } from 'reactflow'
import { useEdges, useNodes } from 'reactflow'
import { RiApps2AddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
@@ -15,6 +15,7 @@ import {
useChecklistBeforePublish,
useNodesReadOnly,
useNodesSyncDraft,
// useWorkflowRunValidation,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
import AppPublisher from '@/app/components/app/app-publisher'
@@ -22,36 +23,44 @@ import { useFeatures } from '@/app/components/base/features/hooks'
import type {
CommonEdgeType,
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import {
BlockEnum,
InputVarType,
isTriggerNode,
} from '@/app/components/workflow/types'
import { useToastContext } from '@/app/components/base/toast'
import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useInvalidateAppTriggers } from '@/service/use-tools'
import type { PublishWorkflowParams } from '@/types/workflow'
import { fetchAppDetail } from '@/service/apps'
import { useStore as useAppStore } from '@/app/components/app/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useIsChatMode } from '@/app/components/workflow/hooks'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
const FeaturesTrigger = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const isChatMode = useIsChatMode()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const {
nodesReadOnly,
getNodesReadOnly,
} = useNodesReadOnly()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
const startVariables = useReactflowStore(
s => s.getNodes().find(node => node.data.type === BlockEnum.Start)?.data.variables,
)
const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput)
const nodes = useNodes<CommonNodeType>()
const hasWorkflowNodes = nodes.length > 0
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = (startNode as Node<StartNodeType>)?.data?.variables
const edges = useEdges<CommonEdgeType>()
const fileSettings = useFeatures(s => s.features.file)
const variables = useMemo(() => {
const data = startVariables || []
@@ -73,6 +82,22 @@ const FeaturesTrigger = () => {
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { notify } = useToastContext()
const startNodeIds = useMemo(
() => nodes.filter(node => node.data.type === BlockEnum.Start).map(node => node.id),
[nodes],
)
const hasUserInputNode = useMemo(() => {
if (!startNodeIds.length)
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 => isTriggerNode(node.data.type as BlockEnum))
), [nodes])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const invalidateAppTriggers = useInvalidateAppTriggers()
const handleShowFeatures = useCallback(() => {
const {
@@ -85,8 +110,6 @@ const FeaturesTrigger = () => {
setShowFeaturesPanel(!showFeaturesPanel)
}, [workflowStore, getNodesReadOnly])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appID! })
@@ -96,14 +119,17 @@ const FeaturesTrigger = () => {
console.error(error)
}
}, [appID, setAppDetail])
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
const nodes = useNodes<CommonNodeType>()
const edges = useEdges<CommonEdgeType>()
// const { validateBeforeRun } = useWorkflowRunValidation()
const needWarningNodes = useChecklist(nodes, edges)
const updatePublishedWorkflow = useInvalidateAppWorkflow()
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
// First check if there are any items in the checklist
// if (!validateBeforeRun())
// throw new Error('Checklist has unresolved items')
if (needWarningNodes.length > 0) {
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
throw new Error('Checklist has unresolved items')
@@ -121,14 +147,16 @@ const FeaturesTrigger = () => {
notify({ type: 'success', message: t('common.api.actionSuccess') })
updatePublishedWorkflow(appID!)
updateAppDetail()
invalidateAppTriggers(appID!)
workflowStore.getState().setPublishedAt(res.created_at)
workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode)
resetWorkflowVersionHistory()
}
}
else {
throw new Error('Checklist failed')
}
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory])
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers])
const onPublisherToggle = useCallback((state: boolean) => {
if (state)
@@ -141,27 +169,34 @@ const FeaturesTrigger = () => {
return (
<>
<Button
className={cn(
'text-components-button-secondary-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleShowFeatures}
>
<RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
{/* Feature button is only visible in chatflow mode (advanced-chat) */}
{isChatMode && (
<Button
className={cn(
'text-components-button-secondary-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleShowFeatures}
>
<RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
)}
<AppPublisher
{...{
publishedAt,
draftUpdatedAt,
disabled: nodesReadOnly,
disabled: nodesReadOnly || !hasWorkflowNodes,
toolPublished,
inputs: variables,
onRefreshData: handleToolConfigureUpdate,
onPublish,
onToggle: onPublisherToggle,
workflowToolAvailable: lastPublishedHasUserInput,
crossAxisOffset: 4,
missingStartNode: !startNode,
hasTriggerNode,
publishDisabled: !hasWorkflowNodes,
}}
/>
</>

View File

@@ -41,8 +41,8 @@ const WorkflowHeader = () => {
return {
normal: {
components: {
left: <ChatVariableTrigger />,
middle: <FeaturesTrigger />,
chatVariableTrigger: <ChatVariableTrigger />,
},
runAndHistoryProps: {
showRunButton: !isChatMode,

View File

@@ -66,6 +66,10 @@ const WorkflowMain = ({
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
} = useWorkflowStartRun()
const availableNodesMetaData = useAvailableNodesMetaData()
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl()
@@ -108,6 +112,10 @@ const WorkflowMain = ({
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData,
getWorkflowRunAndTraceUrl,
exportCheck,
@@ -141,6 +149,10 @@ const WorkflowMain = ({
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData,
getWorkflowRunAndTraceUrl,
exportCheck,

View File

@@ -0,0 +1,99 @@
'use client'
import type { FC } from 'react'
import {
useCallback,
useEffect,
} from 'react'
import { useTranslation } from 'react-i18next'
import { BlockEnum } from '@/app/components/workflow/types'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import Modal from '@/app/components/base/modal'
import StartNodeSelectionPanel from './start-node-selection-panel'
import { useDocLink } from '@/context/i18n'
type WorkflowOnboardingModalProps = {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void
}
const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
isShow,
onClose,
onSelectStartNode,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const handleSelectUserInput = useCallback(() => {
onSelectStartNode(BlockEnum.Start)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
onSelectStartNode(nodeType, toolConfig)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isShow)
onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isShow, onClose])
return (
<>
<Modal
isShow={isShow}
onClose={onClose}
className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg"
overlayOpacity
closable
clickOutsideNotClose
>
<div className="pb-4">
{/* Header */}
<div className="mb-6">
<h3 className="title-2xl-semi-bold mb-2 text-text-primary">
{t('workflow.onboarding.title')}
</h3>
<div className="body-xs-regular leading-4 text-text-tertiary">
{t('workflow.onboarding.description')}{' '}
<a
href={docLink('/guides/workflow/node/start')}
target="_blank"
rel="noopener noreferrer"
className="hover:text-text-accent-hover cursor-pointer text-text-accent underline"
>
{t('workflow.onboarding.learnMore')}
</a>{' '}
{t('workflow.onboarding.aboutStartNode')}
</div>
</div>
{/* Content */}
<StartNodeSelectionPanel
onSelectUserInput={handleSelectUserInput}
onSelectTrigger={handleTriggerSelect}
/>
</div>
</Modal>
{/* ESC tip below modal */}
{isShow && (
<div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary">
<span>{t('workflow.onboarding.escTip.press')}</span>
<kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary">
{t('workflow.onboarding.escTip.key')}
</kbd>
<span>{t('workflow.onboarding.escTip.toDismiss')}</span>
</div>
)}
</>
)
}
export default WorkflowOnboardingModal

View File

@@ -0,0 +1,53 @@
'use client'
import type { FC, ReactNode } from 'react'
import cn from '@/utils/classnames'
type StartNodeOptionProps = {
icon: ReactNode
title: string
subtitle?: string
description: string
onClick: () => void
}
const StartNodeOption: FC<StartNodeOptionProps> = ({
icon,
title,
subtitle,
description,
onClick,
}) => {
return (
<div
onClick={onClick}
className={cn(
'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md',
)}
>
{/* Icon */}
<div className="shrink-0">
{icon}
</div>
{/* Text content */}
<div className="flex h-[74px] flex-col gap-1 py-0.5">
<div className="h-5 leading-5">
<h3 className="system-md-semi-bold text-text-primary">
{title}
{subtitle && (
<span className="system-md-regular text-text-quaternary"> {subtitle}</span>
)}
</h3>
</div>
<div className="h-12 leading-4">
<p className="system-xs-regular text-text-tertiary">
{description}
</p>
</div>
</div>
</div>
)
}
export default StartNodeOption

View File

@@ -0,0 +1,80 @@
'use client'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import StartNodeOption from './start-node-option'
import NodeSelector from '@/app/components/workflow/block-selector'
import { Home } from '@/app/components/base/icons/src/vender/workflow'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import { TabsEnum } from '@/app/components/workflow/block-selector/types'
type StartNodeSelectionPanelProps = {
onSelectUserInput: () => void
onSelectTrigger: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void
}
const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
onSelectUserInput,
onSelectTrigger,
}) => {
const { t } = useTranslation()
const [showTriggerSelector, setShowTriggerSelector] = useState(false)
const handleTriggerClick = useCallback(() => {
setShowTriggerSelector(true)
}, [])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
setShowTriggerSelector(false)
onSelectTrigger(nodeType, toolConfig)
}, [onSelectTrigger])
return (
<div className="grid grid-cols-2 gap-4">
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<Home className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.userInputFull')}
description={t('workflow.onboarding.userInputDescription')}
onClick={onSelectUserInput}
/>
<NodeSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<TriggerAll className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.trigger')}
description={t('workflow.onboarding.triggerDescription')}
onClick={handleTriggerClick}
/>
)}
popupClassName="z-[1200]"
/>
</div>
)
}
export default StartNodeSelectionPanel