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

@@ -22,3 +22,5 @@ export * from './use-DSL'
export * from './use-inspect-vars-crud'
export * from './use-set-workflow-vars-with-value'
export * from './use-workflow-search'
export * from './use-auto-generate-webhook-url'
export * from './use-serial-async-callback'

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { fetchWebhookUrl } from '@/service/apps'
export const useAutoGenerateWebhookUrl = () => {
const reactFlowStore = useStoreApi()
return useCallback(async (nodeId: string) => {
const appId = useAppStore.getState().appDetail?.id
if (!appId)
return
const { getNodes } = reactFlowStore.getState()
const node = getNodes().find(n => n.id === nodeId)
if (!node || node.data.type !== BlockEnum.TriggerWebhook)
return
if (node.data.webhook_url && node.data.webhook_url.length > 0)
return
try {
const response = await fetchWebhookUrl({ appId, nodeId })
const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState()
let hasUpdated = false
const updatedNodes = produce(getLatestNodes(), (draft) => {
const targetNode = draft.find(n => n.id === nodeId)
if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook)
return
targetNode.data = {
...targetNode.data,
webhook_url: response.webhook_url,
webhook_debug_url: response.webhook_debug_url,
}
hasUpdated = true
})
if (hasUpdated)
setNodes(updatedNodes)
}
catch (error: unknown) {
console.error('Failed to auto-generate webhook URL:', error)
}
}, [reactFlowStore])
}

View File

@@ -21,7 +21,9 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean)
} = useNodesMetaData()
const availableNodesType = useMemo(() => availableNodes.map(node => node.metaData.type), [availableNodes])
const availablePrevBlocks = useMemo(() => {
if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource)
if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource
|| nodeType === BlockEnum.TriggerPlugin || nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerSchedule)
return []
return availableNodesType

View File

@@ -4,8 +4,9 @@ import {
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import type {
CommonEdgeType,
CommonNodeType,
Edge,
Node,
@@ -21,20 +22,22 @@ import {
getToolCheckParams,
getValidTreeNodes,
} from '../utils'
import { getTriggerCheckParams } from '../utils/trigger'
import {
CUSTOM_NODE,
} from '../constants'
import {
useGetToolIcon,
useWorkflow,
useNodesMetaData,
} from '../hooks'
import type { ToolNodeType } from '../nodes/tool/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import { useNodesMetaData } from './use-nodes-meta-data'
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
import { useToastContext } from '@/app/components/base/toast'
import { useGetLanguage } from '@/context/i18n'
import type { AgentNodeType } from '../nodes/agent/types'
import { useStrategyProviders } from '@/service/use-strategy'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types'
import type { DataSet } from '@/models/datasets'
@@ -42,6 +45,7 @@ import { fetchDatasets } from '@/service/datasets'
import { MAX_TREE_DEPTH } from '@/config'
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils'
import type { Emoji } from '@/app/components/tools/types'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types'
@@ -50,6 +54,25 @@ import {
useAllCustomTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app'
export type ChecklistItem = {
id: string
type: BlockEnum | string
title: string
toolIcon?: string | Emoji
unConnected?: boolean
errorMessage?: string
canNavigate: boolean
}
const START_NODE_TYPES: BlockEnum[] = [
BlockEnum.Start,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { t } = useTranslation()
@@ -60,9 +83,11 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { data: workflowTools } = useAllWorkflowTools()
const dataSourceList = useStore(s => s.dataSourceList)
const { data: strategyProviders } = useStrategyProviders()
const { data: triggerPlugins } = useAllTriggerPlugins()
const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail)
const { getStartNodes } = useWorkflow()
const getToolIcon = useGetToolIcon()
const appMode = useAppStore.getState().appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const map = useNodesAvailableVarList(nodes)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
@@ -92,16 +117,10 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
return checkData
}, [datasetsDetail, embeddingModelList, rerankModelList])
const needWarningNodes = useMemo(() => {
const list = []
const needWarningNodes = useMemo<ChecklistItem[]>(() => {
const list: ChecklistItem[] = []
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
const startNodes = getStartNodes(filteredNodes)
const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges))
const validNodes = validNodesFlattened.reduce((acc, curr) => {
if (curr.validNodes)
acc.push(...curr.validNodes)
return acc
}, [] as Node[])
const { validNodes } = getValidTreeNodes(filteredNodes, edges)
for (let i = 0; i < filteredNodes.length; i++) {
const node = filteredNodes[i]
@@ -114,6 +133,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
if (node.data.type === BlockEnum.DataSource)
moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language)
if (node.data.type === BlockEnum.TriggerPlugin)
moreDataForCheckValid = getTriggerCheckParams(node.data as PluginTriggerNodeType, triggerPlugins, language)
const toolIcon = getToolIcon(node.data)
if (node.data.type === BlockEnum.Agent) {
const data = node.data as AgentNodeType
@@ -133,7 +155,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
if (node.type === CUSTOM_NODE) {
const checkData = getCheckData(node.data)
let { errorMessage } = nodesExtraData![node.data.type].checkValid(checkData, t, moreDataForCheckValid)
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
let errorMessage = validator ? validator(checkData, t, moreDataForCheckValid).errorMessage : undefined
if (!errorMessage) {
const availableVars = map[node.id].availableVars
@@ -153,19 +176,43 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
}
}
}
if (errorMessage || !validNodes.find(n => n.id === node.id)) {
// Start nodes and Trigger nodes should not show unConnected error if they have validation errors
// or if they are valid start nodes (even without incoming connections)
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
const isUnconnected = !validNodes.find(n => n.id === node.id)
const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck)
if (shouldShowError) {
list.push({
id: node.id,
type: node.data.type,
title: node.data.title,
toolIcon,
unConnected: !validNodes.find(n => n.id === node.id),
unConnected: isUnconnected && !canSkipConnectionCheck,
errorMessage,
canNavigate: true,
})
}
}
}
// Check for start nodes (including triggers)
if (shouldCheckStartNode) {
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
if (startNodesFiltered.length === 0) {
list.push({
id: 'start-node-required',
type: BlockEnum.Start,
title: t('workflow.panel.startNode'),
errorMessage: t('workflow.common.needStartNode'),
canNavigate: false,
})
}
}
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
isRequiredNodesType.forEach((type: string) => {
@@ -175,12 +222,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
type,
title: t(`workflow.blocks.${type}`),
errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }),
canNavigate: false,
})
}
})
return list
}, [nodes, getStartNodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map])
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
return needWarningNodes
}
@@ -194,7 +242,6 @@ export const useChecklistBeforePublish = () => {
const { data: strategyProviders } = useStrategyProviders()
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
const updateTime = useRef(0)
const { getStartNodes } = useWorkflow()
const workflowStore = useWorkflowStore()
const { getNodesAvailableVarList } = useGetNodesAvailableVarList()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
@@ -241,20 +288,11 @@ export const useChecklistBeforePublish = () => {
} = workflowStore.getState()
const nodes = getNodes()
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
const startNodes = getStartNodes(filteredNodes)
const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges))
const validNodes = validNodesFlattened.reduce((acc, curr) => {
if (curr.validNodes)
acc.push(...curr.validNodes)
return acc
}, [] as Node[])
const maxDepthArr = validNodesFlattened.map(item => item.maxDepth)
const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges)
for (let i = 0; i < maxDepthArr.length; i++) {
if (maxDepthArr[i] > MAX_TREE_DEPTH) {
notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) })
return false
}
if (maxDepth > MAX_TREE_DEPTH) {
notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) })
return false
}
// Before publish, we need to fetch datasets detail, in case of the settings of datasets have been changed
const knowledgeRetrievalNodes = filteredNodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval)
@@ -334,10 +372,18 @@ export const useChecklistBeforePublish = () => {
}
}
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
if (startNodesFiltered.length === 0) {
notify({ type: 'error', message: t('workflow.common.needStartNode') })
return false
}
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
for (let i = 0; i < isRequiredNodesType.length; i++) {
const type = isRequiredNodesType[i]
if (!filteredNodes.find(node => node.data.type === type)) {
notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }) })
return false
@@ -345,9 +391,31 @@ export const useChecklistBeforePublish = () => {
}
return true
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, getStartNodes, workflowStore, buildInTools, customTools, workflowTools])
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools])
return {
handleCheckBeforePublish,
}
}
export const useWorkflowRunValidation = () => {
const { t } = useTranslation()
const nodes = useNodes<CommonNodeType>()
const edges = useEdges<CommonEdgeType>()
const needWarningNodes = useChecklist(nodes, edges)
const { notify } = useToastContext()
const validateBeforeRun = useCallback(() => {
if (needWarningNodes.length > 0) {
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
return false
}
return true
}, [needWarningNodes, notify, t])
return {
validateBeforeRun,
hasValidationErrors: needWarningNodes.length > 0,
warningNodes: needWarningNodes,
}
}

View File

@@ -0,0 +1,144 @@
import { useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import { BlockEnum, type CommonNodeType } from '../types'
import { getWorkflowEntryNode } from '../utils/workflow-entry'
import { type TestRunOptions, type TriggerOption, TriggerType } from '../header/test-run-menu'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import BlockIcon from '../block-icon'
import { useStore } from '../store'
import { useAllTriggerPlugins } from '@/service/use-triggers'
export const useDynamicTestRunOptions = (): TestRunOptions => {
const { t } = useTranslation()
const nodes = useNodes()
const buildInTools = useStore(s => s.buildInTools)
const customTools = useStore(s => s.customTools)
const workflowTools = useStore(s => s.workflowTools)
const mcpTools = useStore(s => s.mcpTools)
const { data: triggerPlugins } = useAllTriggerPlugins()
return useMemo(() => {
const allTriggers: TriggerOption[] = []
let userInput: TriggerOption | undefined
for (const node of nodes) {
const nodeData = node.data as CommonNodeType
if (!nodeData?.type) continue
if (nodeData.type === BlockEnum.Start) {
userInput = {
id: node.id,
type: TriggerType.UserInput,
name: nodeData.title || t('workflow.blocks.start'),
icon: (
<BlockIcon
type={BlockEnum.Start}
size='md'
/>
),
nodeId: node.id,
enabled: true,
}
}
else if (nodeData.type === BlockEnum.TriggerSchedule) {
allTriggers.push({
id: node.id,
type: TriggerType.Schedule,
name: nodeData.title || t('workflow.blocks.trigger-schedule'),
icon: (
<BlockIcon
type={BlockEnum.TriggerSchedule}
size='md'
/>
),
nodeId: node.id,
enabled: true,
})
}
else if (nodeData.type === BlockEnum.TriggerWebhook) {
allTriggers.push({
id: node.id,
type: TriggerType.Webhook,
name: nodeData.title || t('workflow.blocks.trigger-webhook'),
icon: (
<BlockIcon
type={BlockEnum.TriggerWebhook}
size='md'
/>
),
nodeId: node.id,
enabled: true,
})
}
else if (nodeData.type === BlockEnum.TriggerPlugin) {
let triggerIcon: string | any
if (nodeData.provider_id) {
const targetTriggers = triggerPlugins || []
triggerIcon = targetTriggers.find(toolWithProvider => toolWithProvider.name === nodeData.provider_id)?.icon
}
const icon = (
<BlockIcon
type={BlockEnum.TriggerPlugin}
size='md'
toolIcon={triggerIcon}
/>
)
allTriggers.push({
id: node.id,
type: TriggerType.Plugin,
name: nodeData.title || (nodeData as any).plugin_name || t('workflow.blocks.trigger-plugin'),
icon,
nodeId: node.id,
enabled: true,
})
}
}
if (!userInput) {
const startNode = getWorkflowEntryNode(nodes as any[])
if (startNode && startNode.data?.type === BlockEnum.Start) {
userInput = {
id: startNode.id,
type: TriggerType.UserInput,
name: (startNode.data as CommonNodeType)?.title || t('workflow.blocks.start'),
icon: (
<BlockIcon
type={BlockEnum.Start}
size='md'
/>
),
nodeId: startNode.id,
enabled: true,
}
}
}
const triggerNodeIds = allTriggers
.map(trigger => trigger.nodeId)
.filter((nodeId): nodeId is string => Boolean(nodeId))
const runAll: TriggerOption | undefined = triggerNodeIds.length > 1 ? {
id: 'run-all',
type: TriggerType.All,
name: t('workflow.common.runAllTriggers'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md">
<TriggerAll className="h-4.5 w-4.5" />
</div>
),
relatedNodeIds: triggerNodeIds,
enabled: true,
} : undefined
return {
userInput,
triggers: allTriggers,
runAll,
}
}, [nodes, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, t])
}

View File

@@ -1,12 +1,40 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import type { Node } from '../types'
import { BlockEnum, isTriggerNode } from '../types'
import { useWorkflowStore } from '../store'
// Entry node (Start/Trigger) wrapper offsets
// The EntryNodeContainer adds a wrapper with status indicator above the actual node
// These offsets ensure alignment happens on the inner node, not the wrapper
const ENTRY_NODE_WRAPPER_OFFSET = {
x: 0, // No horizontal padding on wrapper (px-0)
y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px)
} as const
export const useHelpline = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
// Check if a node is an entry node (Start or Trigger)
const isEntryNode = useCallback((node: Node): boolean => {
return isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start
}, [])
// Get the actual alignment position of a node (accounting for wrapper offset)
const getNodeAlignPosition = useCallback((node: Node) => {
if (isEntryNode(node)) {
return {
x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x,
y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y,
}
}
return {
x: node.position.x,
y: node.position.y,
}
}, [isEntryNode])
const handleSetHelpline = useCallback((node: Node) => {
const { getNodes } = store.getState()
const nodes = getNodes()
@@ -29,6 +57,9 @@ export const useHelpline = () => {
}
}
// Get the actual alignment position for the dragging node
const nodeAlignPos = getNodeAlignPosition(node)
const showHorizontalHelpLineNodes = nodes.filter((n) => {
if (n.id === node.id)
return false
@@ -39,33 +70,52 @@ export const useHelpline = () => {
if (n.data.isInLoop)
return false
const nY = Math.ceil(n.position.y)
const nodeY = Math.ceil(node.position.y)
// Get actual alignment position for comparison node
const nAlignPos = getNodeAlignPosition(n)
const nY = Math.ceil(nAlignPos.y)
const nodeY = Math.ceil(nodeAlignPos.y)
if (nY - nodeY < 5 && nY - nodeY > -5)
return true
return false
}).sort((a, b) => a.position.x - b.position.x)
}).sort((a, b) => {
const aPos = getNodeAlignPosition(a)
const bPos = getNodeAlignPosition(b)
return aPos.x - bPos.x
})
const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length
if (showHorizontalHelpLineNodesLength > 0) {
const first = showHorizontalHelpLineNodes[0]
const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1]
// Use actual alignment positions for help line rendering
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
// For entry nodes, we need to subtract the offset from width since lastPos already includes it
const lastIsEntryNode = isEntryNode(last)
const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width!
const helpLine = {
top: first.position.y,
left: first.position.x,
width: last.position.x + last.width! - first.position.x,
top: firstPos.y,
left: firstPos.x,
width: lastPos.x + lastNodeWidth - firstPos.x,
}
if (node.position.x < first.position.x) {
helpLine.left = node.position.x
helpLine.width = first.position.x + first.width! - node.position.x
if (nodeAlignPos.x < firstPos.x) {
const firstIsEntryNode = isEntryNode(first)
const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width!
helpLine.left = nodeAlignPos.x
helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x
}
if (node.position.x > last.position.x)
helpLine.width = node.position.x + node.width! - first.position.x
if (nodeAlignPos.x > lastPos.x) {
const nodeIsEntryNode = isEntryNode(node)
const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width!
helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x
}
setHelpLineHorizontal(helpLine)
}
@@ -81,33 +131,52 @@ export const useHelpline = () => {
if (n.data.isInLoop)
return false
const nX = Math.ceil(n.position.x)
const nodeX = Math.ceil(node.position.x)
// Get actual alignment position for comparison node
const nAlignPos = getNodeAlignPosition(n)
const nX = Math.ceil(nAlignPos.x)
const nodeX = Math.ceil(nodeAlignPos.x)
if (nX - nodeX < 5 && nX - nodeX > -5)
return true
return false
}).sort((a, b) => a.position.x - b.position.x)
}).sort((a, b) => {
const aPos = getNodeAlignPosition(a)
const bPos = getNodeAlignPosition(b)
return aPos.x - bPos.x
})
const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length
if (showVerticalHelpLineNodesLength > 0) {
const first = showVerticalHelpLineNodes[0]
const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1]
// Use actual alignment positions for help line rendering
const firstPos = getNodeAlignPosition(first)
const lastPos = getNodeAlignPosition(last)
// For entry nodes, we need to subtract the offset from height since lastPos already includes it
const lastIsEntryNode = isEntryNode(last)
const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height!
const helpLine = {
top: first.position.y,
left: first.position.x,
height: last.position.y + last.height! - first.position.y,
top: firstPos.y,
left: firstPos.x,
height: lastPos.y + lastNodeHeight - firstPos.y,
}
if (node.position.y < first.position.y) {
helpLine.top = node.position.y
helpLine.height = first.position.y + first.height! - node.position.y
if (nodeAlignPos.y < firstPos.y) {
const firstIsEntryNode = isEntryNode(first)
const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height!
helpLine.top = nodeAlignPos.y
helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y
}
if (node.position.y > last.position.y)
helpLine.height = node.position.y + node.height! - first.position.y
if (nodeAlignPos.y > lastPos.y) {
const nodeIsEntryNode = isEntryNode(node)
const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height!
helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y
}
setHelpLineVertical(helpLine)
}
@@ -119,7 +188,7 @@ export const useHelpline = () => {
showHorizontalHelpLineNodes,
showVerticalHelpLineNodes,
}
}, [store, workflowStore])
}, [store, workflowStore, getNodeAlignPosition])
return {
handleSetHelpline,

View File

@@ -5,13 +5,35 @@ import {
useSysVarValues,
} from '@/service/use-workflow'
import { FlowType } from '@/types/common'
import { produce } from 'immer'
import { BlockEnum } from '../types'
const varsAppendStartNodeKeys = ['query', 'files']
const useInspectVarsCrud = () => {
const nodesWithInspectVars = useStore(s => s.nodesWithInspectVars)
const partOfNodesWithInspectVars = useStore(s => s.nodesWithInspectVars)
const configsMap = useHooksStore(s => s.configsMap)
const isRagPipeline = configsMap?.flowType === FlowType.ragPipeline
const { data: conversationVars } = useConversationVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '')
const { data: systemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '')
const { data: allSystemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '')
const { varsAppendStartNode, systemVars } = (() => {
if(allSystemVars?.length === 0)
return { varsAppendStartNode: [], systemVars: [] }
const varsAppendStartNode = allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || []
const systemVars = allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || []
return { varsAppendStartNode, systemVars }
})()
const nodesWithInspectVars = (() => {
if(!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0)
return []
const nodesWithInspectVars = produce(partOfNodesWithInspectVars, (draft) => {
draft.forEach((nodeWithVars) => {
if(nodeWithVars.nodeType === BlockEnum.Start)
nodeWithVars.vars = [...nodeWithVars.vars, ...varsAppendStartNode]
})
})
return nodesWithInspectVars
})()
const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars)
const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar)
const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue)

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useStoreApi } from 'reactflow'
import type { SyncCallback } from './use-nodes-sync-draft'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
@@ -28,12 +29,19 @@ export const useNodeDataUpdate = () => {
setNodes(newNodes)
}, [store])
const handleNodeDataUpdateWithSyncDraft = useCallback((payload: NodeDataUpdatePayload) => {
const handleNodeDataUpdateWithSyncDraft = useCallback((
payload: NodeDataUpdatePayload,
options?: {
sync?: boolean
notRefreshWhenSyncError?: boolean
callback?: SyncCallback
},
) => {
if (getNodesReadOnly())
return
handleNodeDataUpdate(payload)
handleSyncWorkflowDraft()
handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback)
}, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly])
return {

View File

@@ -0,0 +1,218 @@
import { useCallback, useMemo } from 'react'
import { BlockEnum, type CommonNodeType } from '../types'
import type { ToolNodeType } from '../nodes/tool/types'
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import { CollectionType } from '@/app/components/tools/types'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidToolsByType,
} from '@/service/use-tools'
import {
useAllTriggerPlugins,
useInvalidateAllTriggerPlugins,
} from '@/service/use-triggers'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import { useStore } from '../store'
import { canFindTool } from '@/utils'
type InstallationState = {
isChecking: boolean
isMissing: boolean
uniqueIdentifier?: string
canInstall: boolean
onInstallSuccess: () => void
shouldDim: boolean
}
const useToolInstallation = (data: ToolNodeType): InstallationState => {
const builtInQuery = useAllBuiltInTools()
const customQuery = useAllCustomTools()
const workflowQuery = useAllWorkflowTools()
const mcpQuery = useAllMCPTools()
const invalidateTools = useInvalidToolsByType(data.provider_type)
const collectionInfo = useMemo(() => {
switch (data.provider_type) {
case CollectionType.builtIn:
return {
list: builtInQuery.data,
isLoading: builtInQuery.isLoading,
}
case CollectionType.custom:
return {
list: customQuery.data,
isLoading: customQuery.isLoading,
}
case CollectionType.workflow:
return {
list: workflowQuery.data,
isLoading: workflowQuery.isLoading,
}
case CollectionType.mcp:
return {
list: mcpQuery.data,
isLoading: mcpQuery.isLoading,
}
default:
return undefined
}
}, [
builtInQuery.data,
builtInQuery.isLoading,
customQuery.data,
customQuery.isLoading,
data.provider_type,
mcpQuery.data,
mcpQuery.isLoading,
workflowQuery.data,
workflowQuery.isLoading,
])
const collection = collectionInfo?.list
const isLoading = collectionInfo?.isLoading ?? false
const isResolved = !!collectionInfo && !isLoading
const matchedCollection = useMemo(() => {
if (!collection || !collection.length)
return undefined
return collection.find((toolWithProvider) => {
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (toolWithProvider.name === data.provider_name)
return true
return false
})
}, [collection, data.plugin_id, data.provider_id, data.provider_name])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
const canInstall = Boolean(data.plugin_unique_identifier)
const onInstallSuccess = useCallback(() => {
if (invalidateTools)
invalidateTools()
}, [invalidateTools])
const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection)
return {
isChecking: !!collectionInfo && !isResolved,
isMissing: isResolved && !matchedCollection,
uniqueIdentifier,
canInstall,
onInstallSuccess,
shouldDim,
}
}
const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => {
const triggerPluginsQuery = useAllTriggerPlugins()
const invalidateTriggers = useInvalidateAllTriggerPlugins()
const triggerProviders = triggerPluginsQuery.data
const isLoading = triggerPluginsQuery.isLoading
const matchedProvider = useMemo(() => {
if (!triggerProviders || !triggerProviders.length)
return undefined
return triggerProviders.find(provider =>
provider.name === data.provider_name
|| provider.id === data.provider_id
|| (data.plugin_id && provider.plugin_id === data.plugin_id),
)
}, [
data.plugin_id,
data.provider_id,
data.provider_name,
triggerProviders,
])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
const canInstall = Boolean(data.plugin_unique_identifier)
const onInstallSuccess = useCallback(() => {
invalidateTriggers()
}, [invalidateTriggers])
const shouldDim = isLoading || (!isLoading && !!triggerProviders && !matchedProvider)
return {
isChecking: isLoading,
isMissing: !isLoading && !!triggerProviders && !matchedProvider,
uniqueIdentifier,
canInstall,
onInstallSuccess,
shouldDim,
}
}
const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => {
const dataSourceList = useStore(s => s.dataSourceList)
const invalidateDataSourceList = useInvalidDataSourceList()
const matchedPlugin = useMemo(() => {
if (!dataSourceList || !dataSourceList.length)
return undefined
return dataSourceList.find((item) => {
if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier)
return true
if (data.plugin_id && item.plugin_id === data.plugin_id)
return true
if (data.provider_name && item.provider === data.provider_name)
return true
return false
})
}, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id
const canInstall = Boolean(data.plugin_unique_identifier)
const onInstallSuccess = useCallback(() => {
invalidateDataSourceList()
}, [invalidateDataSourceList])
const hasLoadedList = dataSourceList !== undefined
const shouldDim = !hasLoadedList || (hasLoadedList && !matchedPlugin)
return {
isChecking: !hasLoadedList,
isMissing: hasLoadedList && !matchedPlugin,
uniqueIdentifier,
canInstall,
onInstallSuccess,
shouldDim,
}
}
export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => {
const toolInstallation = useToolInstallation(data as ToolNodeType)
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType)
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType)
switch (data.type as BlockEnum) {
case BlockEnum.Tool:
return toolInstallation
case BlockEnum.TriggerPlugin:
return triggerInstallation
case BlockEnum.DataSource:
return dataSourceInstallation
default:
return {
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: () => undefined,
shouldDim: false,
}
}
}

View File

@@ -16,9 +16,9 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import type { DataSourceDefaultValue, ToolDefaultValue } from '../block-selector/types'
import type { PluginDefaultValue } from '../block-selector/types'
import type { Edge, Node, OnNodeAdd } from '../types'
import { BlockEnum } from '../types'
import { BlockEnum, isTriggerNode } from '../types'
import { useWorkflowStore } from '../store'
import {
CUSTOM_EDGE,
@@ -63,6 +63,15 @@ import type { RAGPipelineVariables } from '@/models/pipeline'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
// Entry node deletion restriction has been removed to allow empty workflows
// Entry node (Start/Trigger) wrapper offsets for alignment
// Must match the values in use-helpline.ts
const ENTRY_NODE_WRAPPER_OFFSET = {
x: 0,
y: 21, // Adjusted based on visual testing feedback
} as const
export const useNodesInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
@@ -138,21 +147,51 @@ export const useNodesInteractions = () => {
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(n => n.id === node.id)!
if (showVerticalHelpLineNodesLength > 0)
currentNode.position.x = showVerticalHelpLineNodes[0].position.x
else if (restrictPosition.x !== undefined)
currentNode.position.x = restrictPosition.x
else if (restrictLoopPosition.x !== undefined)
currentNode.position.x = restrictLoopPosition.x
else currentNode.position.x = node.position.x
// Check if current dragging node is an entry node
const isCurrentEntryNode = isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start
if (showHorizontalHelpLineNodesLength > 0)
currentNode.position.y = showHorizontalHelpLineNodes[0].position.y
else if (restrictPosition.y !== undefined)
// X-axis alignment with offset consideration
if (showVerticalHelpLineNodesLength > 0) {
const targetNode = showVerticalHelpLineNodes[0]
const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start
// Calculate the wrapper position needed to align the inner nodes
// Target inner position = target.position + target.offset
// Current inner position should equal target inner position
// So: current.position + current.offset = target.position + target.offset
// Therefore: current.position = target.position + target.offset - current.offset
const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0
const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0
currentNode.position.x = targetNode.position.x + targetOffset - currentOffset
}
else if (restrictPosition.x !== undefined) {
currentNode.position.x = restrictPosition.x
}
else if (restrictLoopPosition.x !== undefined) {
currentNode.position.x = restrictLoopPosition.x
}
else {
currentNode.position.x = node.position.x
}
// Y-axis alignment with offset consideration
if (showHorizontalHelpLineNodesLength > 0) {
const targetNode = showHorizontalHelpLineNodes[0]
const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start
const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0
const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0
currentNode.position.y = targetNode.position.y + targetOffset - currentOffset
}
else if (restrictPosition.y !== undefined) {
currentNode.position.y = restrictPosition.y
else if (restrictLoopPosition.y !== undefined)
}
else if (restrictLoopPosition.y !== undefined) {
currentNode.position.y = restrictLoopPosition.y
else currentNode.position.y = node.position.y
}
else {
currentNode.position.y = node.position.y
}
})
setNodes(newNodes)
},
@@ -357,6 +396,7 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_ITERATION_START_NODE) return
if (node.type === CUSTOM_LOOP_START_NODE) return
if (node.data.type === BlockEnum.DataSourceEmpty) return
if (node.data._pluginInstallLocked) return
handleNodeSelect(node.id)
},
[handleNodeSelect],
@@ -735,7 +775,7 @@ export const useNodesInteractions = () => {
nodeType,
sourceHandle = 'source',
targetHandle = 'target',
toolDefaultValue,
pluginDefaultValue,
},
{ prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle },
) => {
@@ -756,7 +796,7 @@ export const useNodesInteractions = () => {
nodesWithSameType.length > 0
? `${defaultValue.title} ${nodesWithSameType.length + 1}`
: defaultValue.title,
...toolDefaultValue,
...pluginDefaultValue,
selected: true,
_showAddVariablePopup:
(nodeType === BlockEnum.VariableAssigner
@@ -1286,7 +1326,7 @@ export const useNodesInteractions = () => {
currentNodeId: string,
nodeType: BlockEnum,
sourceHandle: string,
toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue,
pluginDefaultValue?: PluginDefaultValue,
) => {
if (getNodesReadOnly()) return
@@ -1310,7 +1350,7 @@ export const useNodesInteractions = () => {
nodesWithSameType.length > 0
? `${defaultValue.title} ${nodesWithSameType.length + 1}`
: defaultValue.title,
...toolDefaultValue,
...pluginDefaultValue,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
selected: currentNode.data.selected,
@@ -1656,7 +1696,7 @@ export const useNodesInteractions = () => {
const nodes = getNodes()
const bundledNodes = nodes.filter(
node => node.data._isBundled && node.data.type !== BlockEnum.Start,
node => node.data._isBundled,
)
if (bundledNodes.length) {
@@ -1669,7 +1709,7 @@ export const useNodesInteractions = () => {
if (edgeSelected) return
const selectedNode = nodes.find(
node => node.data.selected && node.data.type !== BlockEnum.Start,
node => node.data.selected,
)
if (selectedNode) handleNodeDelete(selectedNode.id)

View File

@@ -1,12 +1,14 @@
import { useCallback } from 'react'
import {
useStore,
} from '../store'
import {
useNodesReadOnly,
} from './use-workflow'
import { useStore } from '../store'
import { useNodesReadOnly } from './use-workflow'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
export type SyncCallback = {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
}
export const useNodesSyncDraft = () => {
const { getNodesReadOnly } = useNodesReadOnly()
const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft)
@@ -16,11 +18,7 @@ export const useNodesSyncDraft = () => {
const handleSyncWorkflowDraft = useCallback((
sync?: boolean,
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
callback?: SyncCallback,
) => {
if (getNodesReadOnly())
return

View File

@@ -0,0 +1,22 @@
import {
useCallback,
useRef,
} from 'react'
export const useSerialAsyncCallback = <Args extends any[], Result = void>(
fn: (...args: Args) => Promise<Result> | Result,
shouldSkip?: () => boolean,
) => {
const queueRef = useRef<Promise<unknown>>(Promise.resolve())
return useCallback((...args: Args) => {
if (shouldSkip?.())
return Promise.resolve(undefined as Result)
const lastPromise = queueRef.current.catch(() => undefined)
const nextPromise = lastPromise.then(() => fn(...args))
queueRef.current = nextPromise
return nextPromise
}, [fn, shouldSkip])
}

View File

@@ -14,7 +14,6 @@ import {
useWorkflowCanvasMaximize,
useWorkflowMoveMode,
useWorkflowOrganize,
useWorkflowStartRun,
} from '.'
export const useShortcuts = (): void => {
@@ -28,7 +27,6 @@ export const useShortcuts = (): void => {
dimOtherNodes,
undimAllNodes,
} = useNodesInteractions()
const { handleStartWorkflowRun } = useWorkflowStartRun()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleEdgeDelete } = useEdgesInteractions()
@@ -61,9 +59,8 @@ export const useShortcuts = (): void => {
}
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
const { showFeaturesPanel } = workflowStore.getState()
return !showFeaturesPanel && !isEventTargetInputArea(e.target as HTMLElement)
}, [workflowStore])
return !isEventTargetInputArea(e.target as HTMLElement)
}, [])
useKeyPress(['delete', 'backspace'], (e) => {
if (shouldHandleShortcut(e)) {
@@ -99,7 +96,11 @@ export const useShortcuts = (): void => {
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleStartWorkflowRun()
// @ts-expect-error - Dynamic property added by run-and-history component
if (window._toggleTestRunDropdown) {
// @ts-expect-error - Dynamic property added by run-and-history component
window._toggleTestRunDropdown()
}
}
}, { exactMatch: true, useCapture: true })

View File

@@ -1,17 +1,7 @@
import {
useCallback,
useMemo,
} from 'react'
import type {
Node,
} from '../types'
import {
BlockEnum,
} from '../types'
import {
useStore,
useWorkflowStore,
} from '../store'
import { useCallback, useMemo } from 'react'
import type { Node, ToolWithProvider } from '../types'
import { BlockEnum } from '../types'
import { useStore, useWorkflowStore } from '../store'
import { CollectionType } from '@/app/components/tools/types'
import { canFindTool } from '@/utils'
import {
@@ -20,6 +10,32 @@ import {
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
import type { ToolNodeType } from '../nodes/tool/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import type { TriggerWithProvider } from '../block-selector/types'
const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin
const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === BlockEnum.Tool
const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource
const findTriggerPluginIcon = (
identifiers: (string | undefined)[],
triggers: TriggerWithProvider[] | undefined,
) => {
const targetTriggers = triggers || []
for (const identifier of identifiers) {
if (!identifier)
continue
const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier))
if (matched?.icon)
return matched.icon
}
return undefined
}
export const useToolIcon = (data?: Node['data']) => {
const { data: buildInTools } = useAllBuiltInTools()
@@ -27,26 +43,78 @@ export const useToolIcon = (data?: Node['data']) => {
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const dataSourceList = useStore(s => s.dataSourceList)
// const a = useStore(s => s.data)
const { data: triggerPlugins } = useAllTriggerPlugins()
const toolIcon = useMemo(() => {
if (!data)
return ''
if (data.type === BlockEnum.Tool) {
// eslint-disable-next-line sonarjs/no-dead-store
let targetTools = buildInTools || []
if (data.provider_type === CollectionType.builtIn)
targetTools = buildInTools || []
else if (data.provider_type === CollectionType.custom)
targetTools = customTools || []
else if (data.provider_type === CollectionType.mcp)
targetTools = mcpTools || []
else
targetTools = workflowTools || []
return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon
if (isTriggerPluginNode(data)) {
const icon = findTriggerPluginIcon(
[
data.plugin_id,
data.provider_id,
data.provider_name,
],
triggerPlugins,
)
if (icon)
return icon
}
if (data.type === BlockEnum.DataSource)
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
}, [data, dataSourceList, buildInTools, customTools, mcpTools, workflowTools])
if (isToolNode(data)) {
let primaryCollection: ToolWithProvider[] | undefined
switch (data.provider_type) {
case CollectionType.custom:
primaryCollection = customTools
break
case CollectionType.mcp:
primaryCollection = mcpTools
break
case CollectionType.workflow:
primaryCollection = workflowTools
break
case CollectionType.builtIn:
default:
primaryCollection = buildInTools
break
}
const collectionsToSearch = [
primaryCollection,
buildInTools,
customTools,
workflowTools,
mcpTools,
] as Array<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
for (const collection of collectionsToSearch) {
if (!collection || seen.has(collection))
continue
seen.add(collection)
const matched = collection.find((toolWithProvider) => {
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
return data.provider_name === toolWithProvider.name
})
if (matched?.icon)
return matched.icon
}
if (data.provider_icon)
return data.provider_icon
return ''
}
if (isDataSourceNode(data))
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || ''
return ''
}, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins])
return toolIcon
}
@@ -55,27 +123,80 @@ export const useGetToolIcon = () => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const { data: triggerPlugins } = useAllTriggerPlugins()
const workflowStore = useWorkflowStore()
const getToolIcon = useCallback((data: Node['data']) => {
const {
buildInTools: storeBuiltInTools,
customTools: storeCustomTools,
workflowTools: storeWorkflowTools,
mcpTools: storeMcpTools,
dataSourceList,
} = workflowStore.getState()
if (data.type === BlockEnum.Tool) {
// eslint-disable-next-line sonarjs/no-dead-store
let targetTools = buildInTools || []
if (data.provider_type === CollectionType.builtIn)
targetTools = buildInTools || []
else if (data.provider_type === CollectionType.custom)
targetTools = customTools || []
else
targetTools = workflowTools || []
return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon
if (isTriggerPluginNode(data)) {
return findTriggerPluginIcon(
[
data.plugin_id,
data.provider_id,
data.provider_name,
],
triggerPlugins,
)
}
if (data.type === BlockEnum.DataSource)
if (isToolNode(data)) {
const primaryCollection = (() => {
switch (data.provider_type) {
case CollectionType.custom:
return storeCustomTools ?? customTools
case CollectionType.mcp:
return storeMcpTools ?? mcpTools
case CollectionType.workflow:
return storeWorkflowTools ?? workflowTools
case CollectionType.builtIn:
default:
return storeBuiltInTools ?? buildInTools
}
})()
const collectionsToSearch = [
primaryCollection,
storeBuiltInTools ?? buildInTools,
storeCustomTools ?? customTools,
storeWorkflowTools ?? workflowTools,
storeMcpTools ?? mcpTools,
] as Array<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
for (const collection of collectionsToSearch) {
if (!collection || seen.has(collection))
continue
seen.add(collection)
const matched = collection.find((toolWithProvider) => {
if (canFindTool(toolWithProvider.id, data.provider_id))
return true
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
return true
return data.provider_name === toolWithProvider.name
})
if (matched?.icon)
return matched.icon
}
if (data.provider_icon)
return data.provider_icon
return undefined
}
if (isDataSourceNode(data))
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
}, [workflowStore])
return undefined
}, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools])
return getToolIcon
}

View File

@@ -316,7 +316,10 @@ export const useWorkflowUpdate = () => {
edges: initialEdges(edges, nodes),
},
} as any)
setViewport(viewport)
// Only set viewport if it exists and is valid
if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number')
setViewport(viewport)
}, [eventEmitter, reactflow])
return {

View File

@@ -4,10 +4,17 @@ export const useWorkflowStartRun = () => {
const handleStartWorkflowRun = useHooksStore(s => s.handleStartWorkflowRun)
const handleWorkflowStartRunInWorkflow = useHooksStore(s => s.handleWorkflowStartRunInWorkflow)
const handleWorkflowStartRunInChatflow = useHooksStore(s => s.handleWorkflowStartRunInChatflow)
const handleWorkflowTriggerScheduleRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerScheduleRunInWorkflow)
const handleWorkflowTriggerWebhookRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerWebhookRunInWorkflow)
const handleWorkflowTriggerPluginRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerPluginRunInWorkflow)
const handleWorkflowRunAllTriggersInWorkflow = useHooksStore(s => s.handleWorkflowRunAllTriggersInWorkflow)
return {
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
handleWorkflowStartRunInChatflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
}
}

View File

@@ -23,6 +23,10 @@ import {
useStore,
useWorkflowStore,
} from '../store'
import {
getWorkflowEntryNode,
isWorkflowEntryNode,
} from '../utils/workflow-entry'
import {
SUPPORT_OUTPUT_VARS_NODE,
} from '../constants'
@@ -36,11 +40,12 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useNodesMetaData } from '.'
import { AppModeEnum } from '@/types/app'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
return appDetail?.mode === 'advanced-chat'
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}
export const useWorkflow = () => {
@@ -63,6 +68,7 @@ export const useWorkflow = () => {
edges,
} = store.getState()
const nodes = getNodes()
// let startNode = getWorkflowEntryNode(nodes)
const currentNode = nodes.find(node => node.id === nodeId)
let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || []
@@ -232,6 +238,33 @@ export const useWorkflow = () => {
return nodes.filter(node => node.parentId === nodeId)
}, [store])
const isFromStartNode = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(node => node.id === nodeId)
if (!currentNode)
return false
if (isWorkflowEntryNode(currentNode.data.type))
return true
const checkPreviousNodes = (node: Node) => {
const previousNodes = getBeforeNodeById(node.id)
for (const prevNode of previousNodes) {
if (isWorkflowEntryNode(prevNode.data.type))
return true
if (checkPreviousNodes(prevNode))
return true
}
return false
}
return checkPreviousNodes(currentNode)
}, [store, getBeforeNodeById])
const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => {
const { getNodes, setNodes } = store.getState()
const allNodes = getNodes()
@@ -391,6 +424,13 @@ export const useWorkflow = () => {
return !hasCycle(targetNode)
}, [store, getAvailableBlocks])
const getNode = useCallback((nodeId?: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes)
}, [store])
return {
getNodeById,
getTreeLeafNodes,
@@ -407,6 +447,8 @@ export const useWorkflow = () => {
getLoopNodeChildren,
getRootNodesById,
getStartNodes,
isFromStartNode,
getNode,
}
}
@@ -430,14 +472,14 @@ export const useNodesReadOnly = () => {
const historyWorkflowData = useStore(s => s.historyWorkflowData)
const isRestoring = useStore(s => s.isRestoring)
const getNodesReadOnly = useCallback(() => {
const getNodesReadOnly = useCallback((): boolean => {
const {
workflowRunningData,
historyWorkflowData,
isRestoring,
} = workflowStore.getState()
return workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring
return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring)
}, [workflowStore])
return {