dify
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { EdgeProps } from 'reactflow'
|
||||
import {
|
||||
BaseEdge,
|
||||
Position,
|
||||
getBezierPath,
|
||||
} from 'reactflow'
|
||||
import { NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { getEdgeColor } from '@/app/components/workflow/utils'
|
||||
import CustomEdgeLinearGradientRender from '@/app/components/workflow/custom-edge-linear-gradient-render'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
|
||||
const CustomEdge = ({
|
||||
id,
|
||||
data,
|
||||
sourceHandleId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
selected,
|
||||
}: EdgeProps) => {
|
||||
const [
|
||||
edgePath,
|
||||
] = getBezierPath({
|
||||
sourceX: sourceX - 8,
|
||||
sourceY,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: targetX + 8,
|
||||
targetY,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
const {
|
||||
_sourceRunningStatus,
|
||||
_targetRunningStatus,
|
||||
} = data
|
||||
|
||||
const linearGradientId = useMemo(() => {
|
||||
if (
|
||||
(
|
||||
_sourceRunningStatus === NodeRunningStatus.Succeeded
|
||||
|| _sourceRunningStatus === NodeRunningStatus.Failed
|
||||
|| _sourceRunningStatus === NodeRunningStatus.Exception
|
||||
) && (
|
||||
_targetRunningStatus === NodeRunningStatus.Succeeded
|
||||
|| _targetRunningStatus === NodeRunningStatus.Failed
|
||||
|| _targetRunningStatus === NodeRunningStatus.Exception
|
||||
|| _targetRunningStatus === NodeRunningStatus.Running
|
||||
)
|
||||
)
|
||||
return id
|
||||
}, [_sourceRunningStatus, _targetRunningStatus, id])
|
||||
|
||||
const stroke = useMemo(() => {
|
||||
if (selected)
|
||||
return getEdgeColor(NodeRunningStatus.Running)
|
||||
|
||||
if (linearGradientId)
|
||||
return `url(#${linearGradientId})`
|
||||
|
||||
if (data?._connectedNodeIsHovering)
|
||||
return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch)
|
||||
|
||||
return getEdgeColor()
|
||||
}, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
linearGradientId && (
|
||||
<CustomEdgeLinearGradientRender
|
||||
id={linearGradientId}
|
||||
startColor={getEdgeColor(_sourceRunningStatus)}
|
||||
stopColor={getEdgeColor(_targetRunningStatus)}
|
||||
position={{
|
||||
x1: sourceX,
|
||||
y1: sourceY,
|
||||
x2: targetX,
|
||||
y2: targetY,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
opacity: data._waitingRun ? 0.7 : 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomEdge)
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import { NodeSourceHandle } from './node-handle'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'>
|
||||
const ErrorHandleOnNode = ({
|
||||
id,
|
||||
data,
|
||||
}: ErrorHandleOnNodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { error_strategy } = data
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
useEffect(() => {
|
||||
if (error_strategy === ErrorHandleTypeEnum.failBranch)
|
||||
updateNodeInternals(id)
|
||||
}, [error_strategy, id, updateNodeInternals])
|
||||
|
||||
if (!error_strategy)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='relative px-3 pb-2 pt-1'>
|
||||
<div className={cn(
|
||||
'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]',
|
||||
data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover',
|
||||
)}>
|
||||
<div className='system-xs-medium-uppercase text-text-tertiary'>
|
||||
{t('workflow.common.onFailure')}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'system-xs-medium text-text-secondary',
|
||||
data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning',
|
||||
)}>
|
||||
{
|
||||
error_strategy === ErrorHandleTypeEnum.defaultValue && (
|
||||
t('workflow.nodes.common.errorHandle.defaultValue.output')
|
||||
)
|
||||
}
|
||||
{
|
||||
error_strategy === ErrorHandleTypeEnum.failBranch && (
|
||||
t('workflow.nodes.common.errorHandle.failBranch.title')
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
error_strategy === ErrorHandleTypeEnum.failBranch && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleId={ErrorHandleTypeEnum.failBranch}
|
||||
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2 after:!bg-workflow-link-line-failure-button-bg'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorHandleOnNode
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import {
|
||||
Handle,
|
||||
Position,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type NodeHandleProps = {
|
||||
handleId: string
|
||||
handleClassName?: string
|
||||
} & Pick<Node, 'id' | 'data'>
|
||||
|
||||
export const NodeTargetHandle = memo(({
|
||||
data,
|
||||
handleId,
|
||||
handleClassName,
|
||||
}: NodeHandleProps) => {
|
||||
const connected = data._connectedTargetHandleIds?.includes(handleId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
id={handleId}
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
className={cn(
|
||||
'z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
!connected && 'after:opacity-0',
|
||||
(data.type === BlockEnum.Start
|
||||
|| data.type === BlockEnum.TriggerWebhook
|
||||
|| data.type === BlockEnum.TriggerSchedule
|
||||
|| data.type === BlockEnum.TriggerPlugin) && 'opacity-0',
|
||||
handleClassName,
|
||||
)}
|
||||
>
|
||||
</Handle>
|
||||
</>
|
||||
)
|
||||
})
|
||||
NodeTargetHandle.displayName = 'NodeTargetHandle'
|
||||
|
||||
export const NodeSourceHandle = memo(({
|
||||
data,
|
||||
handleId,
|
||||
handleClassName,
|
||||
}: NodeHandleProps) => {
|
||||
const connected = data._connectedSourceHandleIds?.includes(handleId)
|
||||
|
||||
return (
|
||||
<Handle
|
||||
id={handleId}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
className={cn(
|
||||
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
!connected && 'after:opacity-0',
|
||||
handleClassName,
|
||||
)}
|
||||
>
|
||||
</Handle>
|
||||
)
|
||||
})
|
||||
NodeSourceHandle.displayName = 'NodeSourceHandle'
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
ReactElement,
|
||||
} from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
memo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import type {
|
||||
NodeProps,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
|
||||
import {
|
||||
NodeSourceHandle,
|
||||
NodeTargetHandle,
|
||||
} from '../node-handle'
|
||||
import ErrorHandleOnNode from '../error-handle-on-node'
|
||||
|
||||
type NodeChildElement = ReactElement<Partial<NodeProps>>
|
||||
|
||||
type NodeCardProps = NodeProps & {
|
||||
children?: NodeChildElement
|
||||
}
|
||||
|
||||
const BaseCard = ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
}: NodeCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-2xl border-[2px] border-transparent',
|
||||
)}
|
||||
style={{
|
||||
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative pb-1 shadow-xs',
|
||||
'rounded-[15px] border border-transparent',
|
||||
'bg-workflow-block-bg hover:shadow-lg',
|
||||
)}
|
||||
style={{
|
||||
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : '240px',
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center rounded-t-2xl px-3 pb-2 pt-3',
|
||||
)}>
|
||||
<NodeTargetHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleClassName='!top-4 !-left-[9px] !translate-y-0'
|
||||
handleId='target'
|
||||
/>
|
||||
{
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
handleClassName='!top-4 !-right-[9px] !translate-y-0'
|
||||
handleId='source'
|
||||
/>
|
||||
)
|
||||
}
|
||||
<BlockIcon
|
||||
className='mr-2 shrink-0'
|
||||
type={data.type}
|
||||
size='md'
|
||||
/>
|
||||
<div
|
||||
title={data.title}
|
||||
className='system-sm-semibold-uppercase mr-1 flex grow items-center truncate text-text-primary'
|
||||
>
|
||||
<div>
|
||||
{data.title}
|
||||
</div>
|
||||
{
|
||||
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
|
||||
<Tooltip popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='font-extrabold'>
|
||||
{t('workflow.nodes.iteration.parallelModeEnableTitle')}
|
||||
</div>
|
||||
{t('workflow.nodes.iteration.parallelModeEnableDesc')}
|
||||
</div>}
|
||||
>
|
||||
<div className='system-2xs-medium-uppercase ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning '>
|
||||
{t('workflow.nodes.iteration.parallelModeUpper')}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && children && (
|
||||
cloneElement(children, { id, data })
|
||||
)
|
||||
}
|
||||
{
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && children && (
|
||||
<div className='h-[calc(100%-42px)] w-full grow pb-1 pl-1 pr-1'>
|
||||
{cloneElement(children, { id, data })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasErrorHandleNode(data.type) && (
|
||||
<ErrorHandleOnNode
|
||||
id={id}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
|
||||
<div className='system-xs-regular whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary'>
|
||||
{data.desc}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BaseCard)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import QuestionClassifierNode from './question-classifier/node'
|
||||
import IfElseNode from './if-else/node'
|
||||
import IterationNode from './iteration/node'
|
||||
import LoopNode from './loop/node'
|
||||
|
||||
export const NodeComponentMap: Record<string, any> = {
|
||||
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
|
||||
[BlockEnum.IfElse]: IfElseNode,
|
||||
[BlockEnum.Iteration]: IterationNode,
|
||||
[BlockEnum.Loop]: LoopNode,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import { NodeSourceHandle } from '../../node-handle'
|
||||
import { isEmptyRelatedOperator } from '@/app/components/workflow/nodes/if-else/utils'
|
||||
import type { Condition, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
|
||||
import ConditionValue from '@/app/components/workflow/nodes/if-else/components/condition-value'
|
||||
import ConditionFilesListValue from '@/app/components/workflow/nodes/if-else/components/condition-files-list-value'
|
||||
const i18nPrefix = 'workflow.nodes.ifElse'
|
||||
|
||||
const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => {
|
||||
const { data } = props
|
||||
const { t } = useTranslation()
|
||||
const { cases } = data
|
||||
const casesLength = cases.length
|
||||
const checkIsConditionSet = useCallback((condition: Condition) => {
|
||||
if (!condition.variable_selector || condition.variable_selector.length === 0)
|
||||
return false
|
||||
|
||||
if (condition.sub_variable_condition) {
|
||||
const isSet = condition.sub_variable_condition.conditions.every((c) => {
|
||||
if (!c.comparison_operator)
|
||||
return false
|
||||
|
||||
if (isEmptyRelatedOperator(c.comparison_operator!))
|
||||
return true
|
||||
|
||||
return !!c.value
|
||||
})
|
||||
return isSet
|
||||
}
|
||||
else {
|
||||
if (isEmptyRelatedOperator(condition.comparison_operator!))
|
||||
return true
|
||||
|
||||
return !!condition.value
|
||||
}
|
||||
}, [])
|
||||
const conditionNotSet = (<div className='flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'>
|
||||
{t(`${i18nPrefix}.conditionNotSetup`)}
|
||||
</div>)
|
||||
|
||||
return (
|
||||
<div className='px-3'>
|
||||
{
|
||||
cases.map((caseItem, index) => (
|
||||
<div key={caseItem.case_id}>
|
||||
<div className='relative flex h-6 items-center px-1'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='text-[10px] font-semibold text-text-tertiary'>
|
||||
{casesLength > 1 && `CASE ${index + 1}`}
|
||||
</div>
|
||||
<div className='text-[12px] font-semibold text-text-secondary'>{index === 0 ? 'IF' : 'ELIF'}</div>
|
||||
</div>
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={caseItem.case_id}
|
||||
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-0.5'>
|
||||
{caseItem.conditions.map((condition, i) => (
|
||||
<div key={condition.id} className='relative'>
|
||||
{
|
||||
checkIsConditionSet(condition)
|
||||
? (
|
||||
(!isEmptyRelatedOperator(condition.comparison_operator!) && condition.sub_variable_condition)
|
||||
? (
|
||||
<ConditionFilesListValue condition={condition} />
|
||||
)
|
||||
: (
|
||||
<ConditionValue
|
||||
variableSelector={condition.variable_selector!}
|
||||
operator={condition.comparison_operator!}
|
||||
value={condition.value}
|
||||
/>
|
||||
)
|
||||
|
||||
)
|
||||
: conditionNotSet}
|
||||
{i !== caseItem.conditions.length - 1 && (
|
||||
<div className='absolute bottom-[-10px] right-1 z-10 text-[10px] font-medium uppercase leading-4 text-text-accent'>{t(`${i18nPrefix}.${caseItem.logical_operator}`)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div className='relative flex h-6 items-center px-1'>
|
||||
<div className='w-full text-right text-xs font-semibold text-text-secondary'>ELSE</div>
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId='false'
|
||||
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(IfElseNode)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import BaseNode from './base'
|
||||
import { NodeComponentMap } from './constants'
|
||||
|
||||
const CustomNode = (props: NodeProps) => {
|
||||
const nodeData = props.data
|
||||
const NodeComponent = NodeComponentMap[nodeData.type]
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseNode { ...props }>
|
||||
{ NodeComponent && <NodeComponent /> }
|
||||
</BaseNode>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomNode
|
||||
@@ -0,0 +1,28 @@
|
||||
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 '../../node-handle'
|
||||
|
||||
const IterationStartNode = ({ id, data }: NodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs'>
|
||||
<Tooltip popupContent={t('workflow.blocks.iteration-start')} asChild={false}>
|
||||
<div className='flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
|
||||
<RiHome5Fill className='h-3 w-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 default memo(IterationStartNode)
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import {
|
||||
Background,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
|
||||
const Node: FC<NodeProps<IterationNodeType>> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { zoom } = useViewport()
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg',
|
||||
)}>
|
||||
<Background
|
||||
id={`iteration-background-${id}`}
|
||||
className='!z-0 rounded-2xl'
|
||||
gap={[14 / zoom, 14 / zoom]}
|
||||
size={2 / zoom}
|
||||
color='var(--color-workflow-canvas-workflow-dot-color)'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Node)
|
||||
@@ -0,0 +1,28 @@
|
||||
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 '../../node-handle'
|
||||
|
||||
const LoopStartNode = ({ id, data }: NodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg'>
|
||||
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
|
||||
<div className='flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
|
||||
<RiHome5Fill className='h-3 w-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 default memo(LoopStartNode)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type {
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
LOOP_PADDING,
|
||||
} from '@/app/components/workflow/constants'
|
||||
|
||||
export const useNodeLoopInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
|
||||
const handleNodeLoopRerender = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
|
||||
let rightNode: Node
|
||||
let bottomNode: Node
|
||||
|
||||
childrenNodes.forEach((n) => {
|
||||
if (rightNode) {
|
||||
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
|
||||
rightNode = n
|
||||
}
|
||||
else {
|
||||
rightNode = n
|
||||
}
|
||||
if (bottomNode) {
|
||||
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
|
||||
bottomNode = n
|
||||
}
|
||||
else {
|
||||
bottomNode = n
|
||||
}
|
||||
})
|
||||
|
||||
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
|
||||
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
|
||||
|
||||
if (widthShouldExtend || heightShouldExtend) {
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((n) => {
|
||||
if (n.id === nodeId) {
|
||||
if (widthShouldExtend) {
|
||||
n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
|
||||
n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
|
||||
}
|
||||
if (heightShouldExtend) {
|
||||
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
|
||||
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
}
|
||||
}, [store])
|
||||
|
||||
return {
|
||||
handleNodeLoopRerender,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
Background,
|
||||
useNodesInitialized,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import type { LoopNodeType } from '@/app/components/workflow/nodes/loop/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { useNodeLoopInteractions } from './hooks'
|
||||
|
||||
const Node: FC<NodeProps<LoopNodeType>> = ({
|
||||
id,
|
||||
data: _data,
|
||||
}) => {
|
||||
const { zoom } = useViewport()
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const { handleNodeLoopRerender } = useNodeLoopInteractions()
|
||||
|
||||
useEffect(() => {
|
||||
if (nodesInitialized)
|
||||
handleNodeLoopRerender(id)
|
||||
}, [nodesInitialized, id, handleNodeLoopRerender])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg',
|
||||
)}
|
||||
// style={{
|
||||
// width: data.width || 'auto',
|
||||
// }}
|
||||
>
|
||||
<Background
|
||||
id={`loop-background-${id}`}
|
||||
className='!z-0 rounded-2xl'
|
||||
gap={[14 / zoom, 14 / zoom]}
|
||||
size={2 / zoom}
|
||||
color='var(--color-workflow-canvas-workflow-dot-color)'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Node)
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
|
||||
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import { NodeSourceHandle } from '../../node-handle'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.questionClassifiers'
|
||||
|
||||
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = props
|
||||
const topics = data.classes
|
||||
|
||||
return (
|
||||
<div className='mb-1 px-3 py-1'>
|
||||
{
|
||||
!!topics.length && (
|
||||
<div className='mt-2 space-y-0.5'>
|
||||
{topics.map((topic, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='relative'
|
||||
>
|
||||
<InfoPanel
|
||||
title={`${t(`${i18nPrefix}.class`)} ${index + 1}`}
|
||||
content={''}
|
||||
/>
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={topic.id}
|
||||
handleClassName='!top-1/2 !-translate-y-1/2 !-right-[21px]'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import {
|
||||
NoteEditor,
|
||||
NoteEditorContextProvider,
|
||||
} from '@/app/components/workflow/note-node/note-editor'
|
||||
import { THEME_MAP } from '@/app/components/workflow/note-node/constants'
|
||||
import type { NoteNodeType } from '@/app/components/workflow/note-node/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const NoteNode = ({
|
||||
data,
|
||||
}: NodeProps<NoteNodeType>) => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const theme = data.theme
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
|
||||
THEME_MAP[theme].bg,
|
||||
data.selected ? THEME_MAP[theme].border : 'border-black/5',
|
||||
)}
|
||||
style={{
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<NoteEditorContextProvider
|
||||
value={data.text}
|
||||
editable={false}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 shrink-0 rounded-t-md opacity-50',
|
||||
THEME_MAP[theme].title,
|
||||
)}></div>
|
||||
<div className='grow overflow-y-auto px-3 py-2.5'>
|
||||
<div className={cn(
|
||||
data.selected && 'nodrag nopan nowheel cursor-text',
|
||||
)}>
|
||||
<NoteEditor
|
||||
containerElement={ref.current}
|
||||
placeholder={t('workflow.nodes.note.editor.placeholder') || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
data.showAuthor && (
|
||||
<div className='p-3 pt-0 text-xs text-text-tertiary'>
|
||||
{data.author}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
</NoteEditorContextProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NoteNode)
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiZoomInLine,
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useReactFlow,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import TipPopup from '@/app/components/workflow/operator/tip-popup'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
enum ZoomType {
|
||||
zoomIn = 'zoomIn',
|
||||
zoomOut = 'zoomOut',
|
||||
zoomToFit = 'zoomToFit',
|
||||
zoomTo25 = 'zoomTo25',
|
||||
zoomTo50 = 'zoomTo50',
|
||||
zoomTo75 = 'zoomTo75',
|
||||
zoomTo100 = 'zoomTo100',
|
||||
zoomTo200 = 'zoomTo200',
|
||||
}
|
||||
|
||||
const ZoomInOut: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomTo,
|
||||
fitView,
|
||||
} = useReactFlow()
|
||||
const { zoom } = useViewport()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const ZOOM_IN_OUT_OPTIONS = [
|
||||
[
|
||||
{
|
||||
key: ZoomType.zoomTo200,
|
||||
text: '200%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo100,
|
||||
text: '100%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo75,
|
||||
text: '75%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo50,
|
||||
text: '50%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo25,
|
||||
text: '25%',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: ZoomType.zoomToFit,
|
||||
text: t('workflow.operator.zoomToFit'),
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
const handleZoom = (type: string) => {
|
||||
if (type === ZoomType.zoomToFit)
|
||||
fitView()
|
||||
|
||||
if (type === ZoomType.zoomTo25)
|
||||
zoomTo(0.25)
|
||||
|
||||
if (type === ZoomType.zoomTo50)
|
||||
zoomTo(0.5)
|
||||
|
||||
if (type === ZoomType.zoomTo75)
|
||||
zoomTo(0.75)
|
||||
|
||||
if (type === ZoomType.zoomTo100)
|
||||
zoomTo(1)
|
||||
|
||||
if (type === ZoomType.zoomTo200)
|
||||
zoomTo(2)
|
||||
}
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(v => !v)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='top-start'
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg',
|
||||
'p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]',
|
||||
'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-8 w-[98px] items-center justify-between rounded-lg',
|
||||
)}>
|
||||
<TipPopup
|
||||
title={t('workflow.operator.zoomOut')}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<RiZoomOutLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div onClick={handleTrigger} className={cn('system-sm-medium w-[34px] text-text-tertiary hover:text-text-secondary')}>{Number.parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
|
||||
<TipPopup
|
||||
title={t('workflow.operator.zoomIn')}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<RiZoomInLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
{
|
||||
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
|
||||
<Fragment key={i}>
|
||||
{
|
||||
i !== 0 && (
|
||||
<Divider className='m-0' />
|
||||
)
|
||||
}
|
||||
<div className='p-1'>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.key}
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg py-1.5 pl-3 pr-2 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleZoom(option.key)}
|
||||
>
|
||||
<span>{option.text}</span>
|
||||
<div className='flex items-center space-x-0.5'>
|
||||
{
|
||||
option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ZoomInOut)
|
||||
147
dify/web/app/components/workflow/workflow-preview/index.tsx
Normal file
147
dify/web/app/components/workflow/workflow-preview/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
MiniMap,
|
||||
ReactFlowProvider,
|
||||
SelectionMode,
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
} from 'reactflow'
|
||||
import type {
|
||||
EdgeChange,
|
||||
NodeChange,
|
||||
Viewport,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import '../style.css'
|
||||
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 { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants'
|
||||
import CustomConnectionLine from '@/app/components/workflow/custom-connection-line'
|
||||
import {
|
||||
CUSTOM_EDGE,
|
||||
CUSTOM_NODE,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
} from '@/app/components/workflow/constants'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils/workflow-init'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { CUSTOM_NOTE_NODE } from '@/app/components/workflow/note-node/constants'
|
||||
import CustomNode from './components/nodes'
|
||||
import CustomEdge from './components/custom-edge'
|
||||
import ZoomInOut from './components/zoom-in-out'
|
||||
import IterationStartNode from './components/nodes/iteration-start'
|
||||
import LoopStartNode from './components/nodes/loop-start'
|
||||
import CustomNoteNode from './components/note-node'
|
||||
|
||||
const nodeTypes = {
|
||||
[CUSTOM_NODE]: CustomNode,
|
||||
[CUSTOM_NOTE_NODE]: CustomNoteNode,
|
||||
[CUSTOM_SIMPLE_NODE]: CustomNode,
|
||||
[CUSTOM_ITERATION_START_NODE]: IterationStartNode,
|
||||
[CUSTOM_LOOP_START_NODE]: LoopStartNode,
|
||||
}
|
||||
const edgeTypes = {
|
||||
[CUSTOM_EDGE]: CustomEdge,
|
||||
}
|
||||
|
||||
type WorkflowPreviewProps = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
className?: string
|
||||
}
|
||||
const WorkflowPreview = ({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
className,
|
||||
}: WorkflowPreviewProps) => {
|
||||
const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges))
|
||||
const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes))
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => setNodesData(nds => applyNodeChanges(changes, nds)),
|
||||
[],
|
||||
)
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => setEdgesData(eds => applyEdgeChanges(changes, eds)),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
id='workflow-container'
|
||||
className={cn(
|
||||
'relative h-full w-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
style={{
|
||||
width: 102,
|
||||
height: 72,
|
||||
}}
|
||||
maskColor='var(--color-workflow-minimap-bg)'
|
||||
className='!absolute !bottom-14 !left-4 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px]
|
||||
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5'
|
||||
/>
|
||||
<div className='absolute bottom-4 left-4 z-[9] mt-1 flex items-center gap-2'>
|
||||
<ZoomInOut />
|
||||
</div>
|
||||
</>
|
||||
<ReactFlow
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodesData}
|
||||
onNodesChange={onNodesChange}
|
||||
edges={edgesData}
|
||||
onEdgesChange={onEdgesChange}
|
||||
connectionLineComponent={CustomConnectionLine}
|
||||
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
|
||||
defaultViewport={viewport}
|
||||
multiSelectionKeyCode={null}
|
||||
deleteKeyCode={null}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
panOnScroll={false}
|
||||
selectionKeyCode={null}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
minZoom={0.25}
|
||||
>
|
||||
<Background
|
||||
gap={[14, 14]}
|
||||
size={2}
|
||||
className='bg-workflow-canvas-workflow-bg'
|
||||
color='var(--color-workflow-canvas-workflow-dot-color)'
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkflowPreviewWrapper = (props: WorkflowPreviewProps) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowPreview {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowPreviewWrapper
|
||||
Reference in New Issue
Block a user