feat: Parallel Execution of Nodes in Workflows (#8192)
Co-authored-by: StyleZhang <jasonapring2015@outlook.com> Co-authored-by: Yi <yxiaoisme@gmail.com> Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
@@ -21,13 +21,13 @@ type AddProps = {
|
||||
nodeId: string
|
||||
nodeData: CommonNodeType
|
||||
sourceHandle: string
|
||||
branchName?: string
|
||||
isParallel?: boolean
|
||||
}
|
||||
const Add = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
sourceHandle,
|
||||
branchName,
|
||||
isParallel,
|
||||
}: AddProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
@@ -57,23 +57,19 @@ const Add = ({
|
||||
${nodesReadOnly && '!cursor-not-allowed'}
|
||||
`}
|
||||
>
|
||||
{
|
||||
branchName && (
|
||||
<div
|
||||
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-text-placeholder font-semibold'
|
||||
title={branchName.toLocaleUpperCase()}
|
||||
>
|
||||
<div className='inline-block px-0.5 rounded-[5px] bg-background-default truncate'>{branchName.toLocaleUpperCase()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-background-default-dimm'>
|
||||
<RiAddLine className='w-3 h-3' />
|
||||
</div>
|
||||
{t('workflow.panel.selectNextStep')}
|
||||
<div className='flex items-center uppercase'>
|
||||
{
|
||||
isParallel
|
||||
? t('workflow.common.addParallelNode')
|
||||
: t('workflow.panel.selectNextStep')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [branchName, t, nodesReadOnly])
|
||||
}, [t, nodesReadOnly, isParallel])
|
||||
|
||||
return (
|
||||
<BlockSelector
|
||||
|
@@ -0,0 +1,55 @@
|
||||
import Add from './add'
|
||||
import Item from './item'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
|
||||
type ContainerProps = {
|
||||
nodeId: string
|
||||
nodeData: CommonNodeType
|
||||
sourceHandle: string
|
||||
nextNodes: Node[]
|
||||
branchName?: string
|
||||
}
|
||||
|
||||
const Container = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
sourceHandle,
|
||||
nextNodes,
|
||||
branchName,
|
||||
}: ContainerProps) => {
|
||||
return (
|
||||
<div className='p-0.5 space-y-0.5 rounded-[10px] bg-background-section-burn'>
|
||||
{
|
||||
branchName && (
|
||||
<div
|
||||
className='flex items-center px-2 system-2xs-semibold-uppercase text-text-tertiary truncate'
|
||||
title={branchName}
|
||||
>
|
||||
{branchName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
nextNodes.map(nextNode => (
|
||||
<Item
|
||||
key={nextNode.id}
|
||||
nodeId={nextNode.id}
|
||||
data={nextNode.data}
|
||||
sourceHandle='source'
|
||||
/>
|
||||
))
|
||||
}
|
||||
<Add
|
||||
isParallel={!!nextNodes.length}
|
||||
nodeId={nodeId}
|
||||
nodeData={nodeData}
|
||||
sourceHandle={sourceHandle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Container
|
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getConnectedEdges,
|
||||
getOutgoers,
|
||||
@@ -8,13 +9,11 @@ import {
|
||||
import { useToolIcon } from '../../../../hooks'
|
||||
import BlockIcon from '../../../../block-icon'
|
||||
import type {
|
||||
Branch,
|
||||
Node,
|
||||
} from '../../../../types'
|
||||
import { BlockEnum } from '../../../../types'
|
||||
import Add from './add'
|
||||
import Item from './item'
|
||||
import Line from './line'
|
||||
import Container from './container'
|
||||
|
||||
type NextStepProps = {
|
||||
selectedNode: Node
|
||||
@@ -22,15 +21,33 @@ type NextStepProps = {
|
||||
const NextStep = ({
|
||||
selectedNode,
|
||||
}: NextStepProps) => {
|
||||
const { t } = useTranslation()
|
||||
const data = selectedNode.data
|
||||
const toolIcon = useToolIcon(data)
|
||||
const store = useStoreApi()
|
||||
const branches = data._targetBranches || []
|
||||
const branches = useMemo(() => {
|
||||
return data._targetBranches || []
|
||||
}, [data])
|
||||
const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier
|
||||
const edges = useEdges()
|
||||
const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges)
|
||||
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
|
||||
|
||||
const branchesOutgoers = useMemo(() => {
|
||||
if (!branches?.length)
|
||||
return []
|
||||
|
||||
return branches.map((branch) => {
|
||||
const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id)
|
||||
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
|
||||
|
||||
return {
|
||||
branch,
|
||||
nextNodes,
|
||||
}
|
||||
})
|
||||
}, [branches, connectedEdges, outgoers])
|
||||
|
||||
return (
|
||||
<div className='flex py-1'>
|
||||
<div className='shrink-0 relative flex items-center justify-center w-9 h-9 bg-background-default rounded-lg border-[0.5px] border-divider-regular shadow-xs'>
|
||||
@@ -39,59 +56,32 @@ const NextStep = ({
|
||||
toolIcon={toolIcon}
|
||||
/>
|
||||
</div>
|
||||
<Line linesNumber={nodeWithBranches ? branches.length : 1} />
|
||||
<div className='grow'>
|
||||
<Line
|
||||
list={nodeWithBranches ? branchesOutgoers.map(item => item.nextNodes.length + 1) : [1]}
|
||||
/>
|
||||
<div className='grow space-y-2'>
|
||||
{
|
||||
!nodeWithBranches && !!outgoers.length && (
|
||||
<Item
|
||||
nodeId={outgoers[0].id}
|
||||
data={outgoers[0].data}
|
||||
sourceHandle='source'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!nodeWithBranches && !outgoers.length && (
|
||||
<Add
|
||||
!nodeWithBranches && (
|
||||
<Container
|
||||
nodeId={selectedNode!.id}
|
||||
nodeData={selectedNode!.data}
|
||||
sourceHandle='source'
|
||||
nextNodes={outgoers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!branches?.length && nodeWithBranches && (
|
||||
branches.map((branch: Branch) => {
|
||||
const connected = connectedEdges.find(edge => edge.sourceHandle === branch.id)
|
||||
const target = outgoers.find(outgoer => outgoer.id === connected?.target)
|
||||
|
||||
nodeWithBranches && (
|
||||
branchesOutgoers.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={branch.id}
|
||||
className='mb-3 last-of-type:mb-0'
|
||||
>
|
||||
{
|
||||
connected && (
|
||||
<Item
|
||||
data={target!.data!}
|
||||
nodeId={target!.id}
|
||||
sourceHandle={branch.id}
|
||||
branchName={branch.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!connected && (
|
||||
<Add
|
||||
key={branch.id}
|
||||
nodeId={selectedNode!.id}
|
||||
nodeData={selectedNode!.data}
|
||||
sourceHandle={branch.id}
|
||||
branchName={branch.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Container
|
||||
key={item.branch.id}
|
||||
nodeId={selectedNode!.id}
|
||||
nodeData={selectedNode!.data}
|
||||
sourceHandle={item.branch.id}
|
||||
nextNodes={item.nextNodes}
|
||||
branchName={item.branch.name || `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
@@ -1,94 +1,82 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { intersection } from 'lodash-es'
|
||||
import Operator from './operator'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useToolIcon,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ItemProps = {
|
||||
nodeId: string
|
||||
sourceHandle: string
|
||||
branchName?: string
|
||||
data: CommonNodeType
|
||||
}
|
||||
const Item = ({
|
||||
nodeId,
|
||||
sourceHandle,
|
||||
branchName,
|
||||
data,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleNodeChange } = useNodesInteractions()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const toolIcon = useToolIcon(data)
|
||||
const {
|
||||
availablePrevBlocks,
|
||||
availableNextBlocks,
|
||||
} = useAvailableBlocks(data.type, data.isInIteration)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
|
||||
}, [nodeId, sourceHandle, handleNodeChange])
|
||||
const renderTrigger = useCallback((open: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
className={`
|
||||
hidden group-hover:flex
|
||||
${open && '!bg-gray-100 !flex'}
|
||||
`}
|
||||
>
|
||||
{t('workflow.panel.change')}
|
||||
</Button>
|
||||
)
|
||||
}, [t])
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative group flex items-center mb-3 last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer'
|
||||
className='relative group flex items-center last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer'
|
||||
>
|
||||
{
|
||||
branchName && (
|
||||
<div
|
||||
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-gray-500 font-semibold'
|
||||
title={branchName.toLocaleUpperCase()}
|
||||
>
|
||||
<div className='inline-block px-0.5 rounded-[5px] bg-white truncate'>{branchName.toLocaleUpperCase()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<BlockIcon
|
||||
type={data.type}
|
||||
toolIcon={toolIcon}
|
||||
className='shrink-0 mr-1.5'
|
||||
/>
|
||||
<div className='grow system-xs-medium text-text-secondary'>{data.title}</div>
|
||||
<div
|
||||
className='grow system-xs-medium text-text-secondary truncate'
|
||||
title={data.title}
|
||||
>
|
||||
{data.title}
|
||||
</div>
|
||||
{
|
||||
!nodesReadOnly && (
|
||||
<BlockSelector
|
||||
onSelect={handleSelect}
|
||||
placement='top-end'
|
||||
offset={{
|
||||
mainAxis: 6,
|
||||
crossAxis: 8,
|
||||
}}
|
||||
trigger={renderTrigger}
|
||||
popupClassName='!w-[328px]'
|
||||
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
|
||||
/>
|
||||
<>
|
||||
<Button
|
||||
className='hidden group-hover:flex shrink-0 mr-1'
|
||||
size='small'
|
||||
onClick={() => handleNodeSelect(nodeId)}
|
||||
>
|
||||
{t('workflow.common.jumpToNode')}
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden shrink-0 group-hover:flex items-center',
|
||||
open && 'flex',
|
||||
)}
|
||||
>
|
||||
<Operator
|
||||
data={data}
|
||||
nodeId={nodeId}
|
||||
sourceHandle={sourceHandle}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
@@ -1,56 +1,70 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
type LineProps = {
|
||||
linesNumber: number
|
||||
list: number[]
|
||||
}
|
||||
const Line = ({
|
||||
linesNumber,
|
||||
list,
|
||||
}: LineProps) => {
|
||||
const svgHeight = linesNumber * 36 + (linesNumber - 1) * 12
|
||||
const listHeight = list.map((item) => {
|
||||
return item * 36 + (item - 1) * 2 + 12 + 6
|
||||
})
|
||||
const processedList = listHeight.map((item, index) => {
|
||||
if (index === 0)
|
||||
return item
|
||||
|
||||
return listHeight.slice(0, index).reduce((acc, cur) => acc + cur, 0) + item
|
||||
})
|
||||
const processedListLength = processedList.length
|
||||
const svgHeight = processedList[processedListLength - 1] + (processedListLength - 1) * 8
|
||||
|
||||
return (
|
||||
<svg className='shrink-0 w-6' style={{ height: svgHeight }}>
|
||||
{
|
||||
Array(linesNumber).fill(0).map((_, index) => (
|
||||
<g key={index}>
|
||||
{
|
||||
index === 0 && (
|
||||
<>
|
||||
processedList.map((item, index) => {
|
||||
const prevItem = index > 0 ? processedList[index - 1] : 0
|
||||
const space = prevItem + index * 8 + 16
|
||||
return (
|
||||
<g key={index}>
|
||||
{
|
||||
index === 0 && (
|
||||
<>
|
||||
<path
|
||||
d='M0,18 L24,18'
|
||||
strokeWidth={1}
|
||||
fill='none'
|
||||
className='stroke-divider-solid'
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={16}
|
||||
width={1}
|
||||
height={4}
|
||||
className='fill-divider-solid-alt'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
index > 0 && (
|
||||
<path
|
||||
d='M0,18 L24,18'
|
||||
d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`}
|
||||
strokeWidth={1}
|
||||
fill='none'
|
||||
className='stroke-divider-solid'
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={16}
|
||||
width={1}
|
||||
height={4}
|
||||
className='fill-divider-solid-alt'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
index > 0 && (
|
||||
<path
|
||||
d={`M0,18 Q12,18 12,28 L12,${index * 48 + 18 - 10} Q12,${index * 48 + 18} 24,${index * 48 + 18}`}
|
||||
strokeWidth={1}
|
||||
fill='none'
|
||||
className='stroke-divider-solid'
|
||||
/>
|
||||
)
|
||||
}
|
||||
<rect
|
||||
x={23}
|
||||
y={index * 48 + 18 - 2}
|
||||
width={1}
|
||||
height={4}
|
||||
className='fill-divider-solid-alt'
|
||||
/>
|
||||
</g>
|
||||
))
|
||||
)
|
||||
}
|
||||
<rect
|
||||
x={23}
|
||||
y={space}
|
||||
width={1}
|
||||
height={4}
|
||||
className='fill-divider-solid-alt'
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
}
|
||||
</svg>
|
||||
)
|
||||
|
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { intersection } from 'lodash-es'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
|
||||
type ChangeItemProps = {
|
||||
data: CommonNodeType
|
||||
nodeId: string
|
||||
sourceHandle: string
|
||||
}
|
||||
const ChangeItem = ({
|
||||
data,
|
||||
nodeId,
|
||||
sourceHandle,
|
||||
}: ChangeItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { handleNodeChange } = useNodesInteractions()
|
||||
const {
|
||||
availablePrevBlocks,
|
||||
availableNextBlocks,
|
||||
} = useAvailableBlocks(data.type, data.isInIteration)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
|
||||
}, [nodeId, sourceHandle, handleNodeChange])
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
return (
|
||||
<div className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'>
|
||||
{t('workflow.panel.change')}
|
||||
</div>
|
||||
)
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<BlockSelector
|
||||
onSelect={handleSelect}
|
||||
placement='top-end'
|
||||
offset={{
|
||||
mainAxis: 6,
|
||||
crossAxis: 8,
|
||||
}}
|
||||
trigger={renderTrigger}
|
||||
popupClassName='!w-[328px]'
|
||||
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type OperatorProps = {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
data: CommonNodeType
|
||||
nodeId: string
|
||||
sourceHandle: string
|
||||
}
|
||||
const Operator = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
nodeId,
|
||||
sourceHandle,
|
||||
}: OperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
handleNodeDelete,
|
||||
handleNodeDisconnect,
|
||||
} = useNodesInteractions()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-end'
|
||||
offset={{ mainAxis: 4, crossAxis: -4 }}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
|
||||
<Button className='p-0 w-6 h-6'>
|
||||
<RiMoreFill className='w-4 h-4' />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg system-md-regular text-text-secondary'>
|
||||
<div className='p-1'>
|
||||
<ChangeItem
|
||||
data={data}
|
||||
nodeId={nodeId}
|
||||
sourceHandle={sourceHandle}
|
||||
/>
|
||||
<div
|
||||
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
|
||||
onClick={() => handleNodeDisconnect(nodeId)}
|
||||
>
|
||||
{t('workflow.common.disconnect')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
|
||||
onClick={() => handleNodeDelete(nodeId)}
|
||||
>
|
||||
{t('common.operation.delete')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Operator
|
@@ -9,16 +9,22 @@ import {
|
||||
Handle,
|
||||
Position,
|
||||
} from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import type { Node } from '../../../types'
|
||||
import BlockSelector from '../../../block-selector'
|
||||
import type { ToolDefaultValue } from '../../../block-selector/types'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useWorkflow,
|
||||
} from '../../../hooks'
|
||||
import { useStore } from '../../../store'
|
||||
import {
|
||||
useStore,
|
||||
} from '../../../store'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type NodeHandleProps = {
|
||||
handleId: string
|
||||
@@ -38,9 +44,7 @@ export const NodeTargetHandle = memo(({
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const connected = data._connectedTargetHandleIds?.includes(handleId)
|
||||
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration)
|
||||
const isConnectable = !!availablePrevBlocks.length && (
|
||||
!data.isIterationStart
|
||||
)
|
||||
const isConnectable = !!availablePrevBlocks.length
|
||||
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
@@ -112,12 +116,15 @@ export const NodeSourceHandle = memo(({
|
||||
handleClassName,
|
||||
nodeSelectorClassName,
|
||||
}: NodeHandleProps) => {
|
||||
const { t } = useTranslation()
|
||||
const notInitialWorkflow = useStore(s => s.notInitialWorkflow)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
|
||||
const isConnectable = !!availableNextBlocks.length
|
||||
const isChatMode = useIsChatMode()
|
||||
const { checkParallelLimit } = useWorkflow()
|
||||
|
||||
const connected = data._connectedSourceHandleIds?.includes(handleId)
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
@@ -125,9 +132,9 @@ export const NodeSourceHandle = memo(({
|
||||
}, [])
|
||||
const handleHandleClick = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!connected)
|
||||
if (checkParallelLimit(id))
|
||||
setOpen(v => !v)
|
||||
}, [connected])
|
||||
}, [checkParallelLimit, id])
|
||||
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
@@ -142,12 +149,25 @@ export const NodeSourceHandle = memo(({
|
||||
}, [handleNodeAdd, id, handleId])
|
||||
|
||||
useEffect(() => {
|
||||
if (notInitialWorkflow && data.type === BlockEnum.Start)
|
||||
if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode)
|
||||
setOpen(true)
|
||||
}, [notInitialWorkflow, data.type])
|
||||
}, [notInitialWorkflow, data.type, isChatMode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
<div>
|
||||
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.click.title')}</span>
|
||||
{t('workflow.common.parallelTip.click.desc')}
|
||||
</div>
|
||||
<div>
|
||||
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.drag.title')}</span>
|
||||
{t('workflow.common.parallelTip.drag.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Handle
|
||||
id={handleId}
|
||||
type='source'
|
||||
@@ -163,7 +183,7 @@ export const NodeSourceHandle = memo(({
|
||||
onClick={handleHandleClick}
|
||||
>
|
||||
{
|
||||
!connected && isConnectable && !getNodesReadOnly() && (
|
||||
isConnectable && !getNodesReadOnly() && (
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
@@ -181,7 +201,7 @@ export const NodeSourceHandle = memo(({
|
||||
)
|
||||
}
|
||||
</Handle>
|
||||
</>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
NodeSourceHandle.displayName = 'NodeSourceHandle'
|
||||
|
@@ -28,8 +28,8 @@ const NodeResizer = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
icon = <Icon />,
|
||||
minWidth = 272,
|
||||
minHeight = 176,
|
||||
minWidth = 258,
|
||||
minHeight = 152,
|
||||
maxWidth,
|
||||
}: NodeResizerProps) => {
|
||||
const { handleNodeResize } = useNodesInteractions()
|
||||
|
@@ -14,6 +14,7 @@ import {
|
||||
RiErrorWarningLine,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from '../../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
@@ -43,6 +44,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
data,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const nodeRef = useRef<HTMLDivElement>(null)
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
|
||||
@@ -80,6 +82,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
className={cn(
|
||||
'flex border-[2px] rounded-2xl',
|
||||
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
|
||||
!showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight',
|
||||
)}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
@@ -100,6 +103,13 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
data._isBundled && '!shadow-lg',
|
||||
)}
|
||||
>
|
||||
{
|
||||
data._inParallelHovering && (
|
||||
<div className='absolute left-2 -top-2.5 top system-2xs-medium-uppercase text-text-tertiary z-10'>
|
||||
{t('workflow.common.parallelRun')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
data._showAddVariablePopup && (
|
||||
<AddVariablePopupWithPosition
|
||||
|
@@ -28,7 +28,6 @@ export enum ComparisonOperator {
|
||||
lessThanOrEqual = '≤',
|
||||
isNull = 'is null',
|
||||
isNotNull = 'is not null',
|
||||
regexMatch = 'regex match',
|
||||
}
|
||||
|
||||
export type Condition = {
|
||||
|
@@ -30,7 +30,6 @@ export const getOperators = (type?: VarType) => {
|
||||
ComparisonOperator.isNot,
|
||||
ComparisonOperator.empty,
|
||||
ComparisonOperator.notEmpty,
|
||||
ComparisonOperator.regexMatch,
|
||||
]
|
||||
case VarType.number:
|
||||
return [
|
||||
|
@@ -0,0 +1 @@
|
||||
export const CUSTOM_ITERATION_START_NODE = 'custom-iteration-start'
|
21
web/app/components/workflow/nodes/iteration-start/default.ts
Normal file
21
web/app/components/workflow/nodes/iteration-start/default.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { IterationStartNodeType } from './types'
|
||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/constants'
|
||||
|
||||
const nodeDefault: NodeDefault<IterationStartNodeType> = {
|
||||
defaultValue: {},
|
||||
getAvailablePrevNodes() {
|
||||
return []
|
||||
},
|
||||
getAvailableNextNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
|
||||
return nodes
|
||||
},
|
||||
checkValid() {
|
||||
return {
|
||||
isValid: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
42
web/app/components/workflow/nodes/iteration-start/index.tsx
Normal file
42
web/app/components/workflow/nodes/iteration-start/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import { RiHome5Fill } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
|
||||
|
||||
const IterationStartNode = ({ id, data }: NodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='group flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white'>
|
||||
<Tooltip popupContent={t('workflow.blocks.iteration-start')} asChild={false}>
|
||||
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
|
||||
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
|
||||
handleId='source'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const IterationStartNodeDumb = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='relative left-[17px] top-[21px] flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white z-[11]'>
|
||||
<Tooltip popupContent={t('workflow.blocks.iteration-start')} asChild={false}>
|
||||
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
|
||||
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(IterationStartNode)
|
@@ -0,0 +1,3 @@
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
|
||||
export type IterationStartNodeType = CommonNodeType
|
@@ -2,87 +2,49 @@ import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import produce from 'immer'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
generateNewNode,
|
||||
} from '../../utils'
|
||||
import {
|
||||
WorkflowHistoryEvent,
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useWorkflowHistory,
|
||||
} from '../../hooks'
|
||||
import { NODES_INITIAL_DATA } from '../../constants'
|
||||
import InsertBlock from './insert-block'
|
||||
import type { IterationNodeType } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import { IterationStart } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import type {
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '@/app/components/workflow/types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type AddBlockProps = {
|
||||
iterationNodeId: string
|
||||
iterationNodeData: IterationNodeType
|
||||
}
|
||||
const AddBlock = ({
|
||||
iterationNodeId,
|
||||
iterationNodeData,
|
||||
}: AddBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true)
|
||||
const { availablePrevBlocks } = useAvailableBlocks(iterationNodeData.startNodeType, true)
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === type)
|
||||
const newNode = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[type],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
|
||||
...(toolDefaultValue || {}),
|
||||
isIterationStart: true,
|
||||
isInIteration: true,
|
||||
iteration_id: iterationNodeId,
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
},
|
||||
position: {
|
||||
x: 117,
|
||||
y: 85,
|
||||
{
|
||||
prevNodeId: iterationNodeData.start_node_id,
|
||||
prevNodeSourceHandle: 'source',
|
||||
},
|
||||
zIndex: 1001,
|
||||
parentId: iterationNodeId,
|
||||
extent: 'parent',
|
||||
})
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (node.id === iterationNodeId) {
|
||||
node.data._children = [newNode.id]
|
||||
node.data.start_node_id = newNode.id
|
||||
node.data.startNodeType = newNode.data.type
|
||||
}
|
||||
})
|
||||
draft.push(newNode)
|
||||
})
|
||||
setNodes(newNodes)
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
|
||||
}, [store, t, iterationNodeId, saveStateToHistory])
|
||||
)
|
||||
}, [handleNodeAdd, iterationNodeData.start_node_id])
|
||||
|
||||
const renderTriggerElement = useCallback((open: boolean) => {
|
||||
return (
|
||||
@@ -98,35 +60,18 @@ const AddBlock = ({
|
||||
}, [nodesReadOnly, t])
|
||||
|
||||
return (
|
||||
<div className='absolute top-12 left-6 flex items-center h-8 z-10'>
|
||||
<Tooltip popupContent={t('workflow.blocks.iteration-start')}>
|
||||
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-black/[0.02] shadow-md bg-primary-500'>
|
||||
<IterationStart className='w-4 h-4 text-white' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className='absolute top-7 left-14 flex items-center h-8 z-10'>
|
||||
<div className='group/insert relative w-16 h-0.5 bg-gray-300'>
|
||||
{
|
||||
iterationNodeData.startNodeType && (
|
||||
<InsertBlock
|
||||
startNodeId={iterationNodeData.start_node_id}
|
||||
availableBlocksTypes={availablePrevBlocks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className='absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-2 bg-primary-500'></div>
|
||||
</div>
|
||||
{
|
||||
!iterationNodeData.startNodeType && (
|
||||
<BlockSelector
|
||||
disabled={nodesReadOnly}
|
||||
onSelect={handleSelect}
|
||||
trigger={renderTriggerElement}
|
||||
triggerInnerClassName='inline-flex'
|
||||
popupClassName='!min-w-[256px]'
|
||||
availableBlocksTypes={availableNextBlocks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<BlockSelector
|
||||
disabled={nodesReadOnly}
|
||||
onSelect={handleSelect}
|
||||
trigger={renderTriggerElement}
|
||||
triggerInnerClassName='inline-flex'
|
||||
popupClassName='!min-w-[256px]'
|
||||
availableBlocksTypes={availableNextBlocks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ const nodeDefault: NodeDefault<IterationNodeType> = {
|
||||
start_node_id: '',
|
||||
iterator_selector: [],
|
||||
output_selector: [],
|
||||
_children: [],
|
||||
},
|
||||
getAvailablePrevNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode
|
||||
|
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useNodesInteractions } from '../../hooks'
|
||||
import type {
|
||||
BlockEnum,
|
||||
OnSelectBlock,
|
||||
} from '../../types'
|
||||
import BlockSelector from '../../block-selector'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type InsertBlockProps = {
|
||||
startNodeId: string
|
||||
availableBlocksTypes: BlockEnum[]
|
||||
}
|
||||
const InsertBlock = ({
|
||||
startNodeId,
|
||||
availableBlocksTypes,
|
||||
}: InsertBlockProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
}, [])
|
||||
const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType,
|
||||
toolDefaultValue,
|
||||
},
|
||||
{
|
||||
nextNodeId: startNodeId,
|
||||
nextNodeTargetHandle: 'target',
|
||||
},
|
||||
)
|
||||
}, [startNodeId, handleNodeAdd])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'nopan nodrag',
|
||||
'hidden group-hover/insert:block absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2',
|
||||
open && '!block',
|
||||
)}
|
||||
>
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
asChild
|
||||
onSelect={handleInsert}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
triggerClassName={() => 'hover:scale-125 transition-all'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InsertBlock)
|
@@ -8,6 +8,7 @@ import {
|
||||
useNodesInitialized,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { IterationStartNodeDumb } from '../iteration-start'
|
||||
import { useNodeIterationInteractions } from './use-interactions'
|
||||
import type { IterationNodeType } from './types'
|
||||
import AddBlock from './add-block'
|
||||
@@ -29,7 +30,7 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative min-w-[258px] min-h-[118px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
|
||||
'relative min-w-[240px] min-h-[90px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
|
||||
)}>
|
||||
<Background
|
||||
id={`iteration-background-${id}`}
|
||||
@@ -38,10 +39,19 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
|
||||
size={2 / zoom}
|
||||
color='#E4E5E7'
|
||||
/>
|
||||
<AddBlock
|
||||
iterationNodeId={id}
|
||||
iterationNodeData={data}
|
||||
/>
|
||||
{
|
||||
data._isCandidate && (
|
||||
<IterationStartNodeDumb />
|
||||
)
|
||||
}
|
||||
{
|
||||
data._children!.length === 1 && (
|
||||
<AddBlock
|
||||
iterationNodeId={id}
|
||||
iterationNodeData={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ import {
|
||||
ITERATION_PADDING,
|
||||
NODES_INITIAL_DATA,
|
||||
} from '../../constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
|
||||
|
||||
export const useNodeIterationInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -107,12 +108,12 @@ export const useNodeIterationInteractions = () => {
|
||||
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
|
||||
|
||||
return childrenNodes.map((child, index) => {
|
||||
const childNodeType = child.data.type as BlockEnum
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
|
||||
const newNode = generateNewNode({
|
||||
const { newNode } = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[childNodeType],
|
||||
...child.data,
|
||||
@@ -121,6 +122,7 @@ export const useNodeIterationInteractions = () => {
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${childNodeType}`),
|
||||
iteration_id: newNodeId,
|
||||
},
|
||||
position: child.position,
|
||||
positionAbsolute: child.positionAbsolute,
|
||||
|
Reference in New Issue
Block a user