diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx
index 6f2389aad2..5a1fd825fb 100644
--- a/web/app/components/workflow/candidate-node.tsx
+++ b/web/app/components/workflow/candidate-node.tsx
@@ -13,7 +13,7 @@ import {
useWorkflowStore,
} from './store'
import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks'
-import { CUSTOM_NODE } from './constants'
+import { CUSTOM_NODE, ITERATION_PADDING } from './constants'
import { getIterationStartNode, getLoopStartNode } from './utils'
import CustomNode from './nodes'
import CustomNoteNode from './note-node'
@@ -41,7 +41,33 @@ const CandidateNode = () => {
} = store.getState()
const { screenToFlowPosition } = reactflow
const nodes = getNodes()
- const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
+ // Get mouse position in flow coordinates (this is where the top-left corner should be)
+ let { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
+
+ // If the node has a parent (e.g., inside iteration), apply constraints and convert to relative position
+ if (candidateNode.parentId) {
+ const parentNode = nodes.find(node => node.id === candidateNode.parentId)
+ if (parentNode && parentNode.position) {
+ // Apply boundary constraints for iteration nodes
+ if (candidateNode.data.isInIteration) {
+ const nodeWidth = candidateNode.width || 0
+ const nodeHeight = candidateNode.height || 0
+ const minX = parentNode.position.x + ITERATION_PADDING.left
+ const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth
+ const minY = parentNode.position.y + ITERATION_PADDING.top
+ const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight
+
+ // Constrain position
+ x = Math.max(minX, Math.min(maxX, x))
+ y = Math.max(minY, Math.min(maxY, y))
+ }
+
+ // Convert to relative position
+ x = x - parentNode.position.x
+ y = y - parentNode.position.y
+ }
+ }
+
const newNodes = produce(nodes, (draft) => {
draft.push({
...candidateNode,
@@ -59,6 +85,20 @@ const CandidateNode = () => {
if (candidateNode.data.type === BlockEnum.Loop)
draft.push(getLoopStartNode(candidateNode.id))
+
+ // Update parent iteration node's _children array
+ if (candidateNode.parentId && candidateNode.data.isInIteration) {
+ const parentNode = draft.find(node => node.id === candidateNode.parentId)
+ if (parentNode && parentNode.data.type === BlockEnum.Iteration) {
+ if (!parentNode.data._children)
+ parentNode.data._children = []
+
+ parentNode.data._children.push({
+ nodeId: candidateNode.id,
+ nodeType: candidateNode.data.type,
+ })
+ }
+ }
})
setNodes(newNodes)
if (candidateNode.type === CUSTOM_NOTE_NODE)
@@ -84,6 +124,34 @@ const CandidateNode = () => {
if (!candidateNode)
return null
+ // Apply boundary constraints if node is inside iteration
+ if (candidateNode.parentId && candidateNode.data.isInIteration) {
+ const { getNodes } = store.getState()
+ const nodes = getNodes()
+ const parentNode = nodes.find(node => node.id === candidateNode.parentId)
+
+ if (parentNode && parentNode.position) {
+ const { screenToFlowPosition, flowToScreenPosition } = reactflow
+ // Get mouse position in flow coordinates
+ const flowPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
+
+ // Calculate boundaries in flow coordinates
+ const nodeWidth = candidateNode.width || 0
+ const nodeHeight = candidateNode.height || 0
+ const minX = parentNode.position.x + ITERATION_PADDING.left
+ const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth
+ const minY = parentNode.position.y + ITERATION_PADDING.top
+ const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight
+
+ // Constrain position
+ const constrainedX = Math.max(minX, Math.min(maxX, flowPosition.x))
+ const constrainedY = Math.max(minY, Math.min(maxY, flowPosition.y))
+
+ // Convert back to screen coordinates
+ flowToScreenPosition({ x: constrainedX, y: constrainedY })
+ }
+ }
+
return (
{
targetHandle = 'target',
toolDefaultValue,
},
- { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle },
+ { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle, skipAutoConnect },
) => {
if (getNodesReadOnly()) return
@@ -830,7 +830,7 @@ export const useNodesInteractions = () => {
}
let newEdge = null
- if (nodeType !== BlockEnum.DataSource) {
+ if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) {
newEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
@@ -970,6 +970,7 @@ export const useNodesInteractions = () => {
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd
+ && !skipAutoConnect
) {
newEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
@@ -1119,7 +1120,7 @@ export const useNodesInteractions = () => {
)
let newPrevEdge = null
- if (nodeType !== BlockEnum.DataSource) {
+ if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) {
newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE,
@@ -1159,6 +1160,7 @@ export const useNodesInteractions = () => {
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd
+ && !skipAutoConnect
) {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
index a871e60e3a..5fa3257a3f 100644
--- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
+++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
@@ -15,7 +15,9 @@ import {
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
-import type { Node } from '@/app/components/workflow/types'
+import { BlockEnum, type Node } from '@/app/components/workflow/types'
+import PanelAddBlock from '@/app/components/workflow/nodes/iteration/panel-add-block'
+import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
type PanelOperatorPopupProps = {
id: string
@@ -51,6 +53,9 @@ const PanelOperatorPopup = ({
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>
+ {data.type === BlockEnum.Iteration && (
+
+ )}
{
canRunBySingle(data.type, isChildNode) && (
React.ReactNode
+ offset?: OffsetOptions
+ iterationNodeData: IterationNodeType
+ onClosePopup: () => void
+}
+const AddBlock = ({
+ offset,
+ iterationNodeData,
+ onClosePopup,
+}: AddBlockProps) => {
+ const { t } = useTranslation()
+ const store = useStoreApi()
+ const workflowStore = useWorkflowStore()
+ const { nodesReadOnly } = useNodesReadOnly()
+ const { handlePaneContextmenuCancel } = usePanelInteractions()
+ const [open, setOpen] = useState(false)
+ const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false)
+ const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
+
+ const handleOpenChange = useCallback((open: boolean) => {
+ setOpen(open)
+ if (!open)
+ handlePaneContextmenuCancel()
+ }, [handlePaneContextmenuCancel])
+
+ const handleSelect = useCallback
((type, toolDefaultValue) => {
+ const { getNodes } = store.getState()
+ const nodes = getNodes()
+ const nodesWithSameType = nodes.filter(node => node.data.type === type)
+ const { defaultValue } = nodesMetaDataMap![type]
+
+ // Find the parent iteration node
+ const parentIterationNode = nodes.find(node => node.data.start_node_id === iterationNodeData.start_node_id)
+
+ const { newNode } = generateNewNode({
+ type: getNodeCustomTypeByNodeDataType(type),
+ data: {
+ ...(defaultValue as any),
+ title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
+ ...toolDefaultValue,
+ _isCandidate: true,
+ // Set iteration-specific properties
+ isInIteration: true,
+ iteration_id: parentIterationNode?.id,
+ },
+ position: {
+ x: 0,
+ y: 0,
+ },
+ })
+
+ // Set parent and z-index for iteration child
+ if (parentIterationNode) {
+ newNode.parentId = parentIterationNode.id
+ newNode.extent = 'parent' as any
+ newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
+ }
+
+ workflowStore.setState({
+ candidateNode: newNode,
+ })
+ onClosePopup()
+ }, [store, workflowStore, nodesMetaDataMap, iterationNodeData.start_node_id, onClosePopup])
+
+ const renderTrigger = () => {
+ return (
+
+ {t('workflow.common.addBlock')}
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default memo(AddBlock)
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts
index f6a706a982..55afde0bfd 100644
--- a/web/app/components/workflow/types.ts
+++ b/web/app/components/workflow/types.ts
@@ -379,6 +379,7 @@ export type OnNodeAdd = (
prevNodeSourceHandle?: string
nextNodeId?: string
nextNodeTargetHandle?: string
+ skipAutoConnect?: boolean
},
) => void