Feat/loop break node (#17268)

This commit is contained in:
zxhlyh
2025-04-01 16:52:07 +08:00
committed by GitHub
parent 627a9e2ce1
commit 713902dc47
64 changed files with 1397 additions and 139 deletions

View File

@@ -138,9 +138,6 @@ const ConditionWrap: FC<Props> = ({
)}
</div>
</div>
{!isSubVariable && (
<div className='mx-3 my-2 h-[1px] bg-divider-subtle'></div>
)}
</div>
</>
)

View File

@@ -0,0 +1,13 @@
import { useTranslation } from 'react-i18next'
const Empty = () => {
const { t } = useTranslation()
return (
<div className='system-xs-regular flex h-10 items-center justify-center rounded-[10px] bg-background-section text-text-tertiary'>
{t('workflow.nodes.loop.setLoopVariables')}
</div>
)
}
export default Empty

View File

@@ -0,0 +1,144 @@
import {
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type {
LoopVariable,
} from '@/app/components/workflow/nodes/loop/types'
import type {
Var,
} from '@/app/components/workflow/types'
import {
ValueType,
VarType,
} from '@/app/components/workflow/types'
const objectPlaceholder = `# example
# {
# "name": "ray",
# "age": 20
# }`
const arrayStringPlaceholder = `# example
# [
# "value1",
# "value2"
# ]`
const arrayNumberPlaceholder = `# example
# [
# 100,
# 200
# ]`
const arrayObjectPlaceholder = `# example
# [
# {
# "name": "ray",
# "age": 20
# },
# {
# "name": "lily",
# "age": 18
# }
# ]`
type FormItemProps = {
nodeId: string
item: LoopVariable
onChange: (value: any) => void
}
const FormItem = ({
nodeId,
item,
onChange,
}: FormItemProps) => {
const { t } = useTranslation()
const { value_type, var_type, value } = item
const handleInputChange = useCallback((e: any) => {
onChange(e.target.value)
}, [onChange])
const handleChange = useCallback((value: any) => {
onChange(value)
}, [onChange])
const filterVar = useCallback((variable: Var) => {
return variable.type === var_type
}, [var_type])
const editorMinHeight = useMemo(() => {
if (var_type === VarType.arrayObject)
return '240px'
return '120px'
}, [var_type])
const placeholder = useMemo(() => {
if (var_type === VarType.arrayString)
return arrayStringPlaceholder
if (var_type === VarType.arrayNumber)
return arrayNumberPlaceholder
if (var_type === VarType.arrayObject)
return arrayObjectPlaceholder
return objectPlaceholder
}, [var_type])
return (
<div>
{
value_type === ValueType.variable && (
<VarReferencePicker
readonly={false}
nodeId={nodeId}
isShowNodeName
value={value}
onChange={handleChange}
filterVar={filterVar}
placeholder={t('workflow.nodes.assigner.setParameter') as string}
/>
)
}
{
value_type === ValueType.constant && var_type === VarType.string && (
<Textarea
value={value}
onChange={handleInputChange}
className='min-h-12 w-full'
/>
)
}
{
value_type === ValueType.constant && var_type === VarType.number && (
<Input
type="number"
value={value}
onChange={handleInputChange}
className='w-full'
/>
)
}
{
value_type === ValueType.constant
&& (var_type === VarType.object || var_type === VarType.arrayString || var_type === VarType.arrayNumber || var_type === VarType.arrayObject)
&& (
<div className='w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1' style={{ height: editorMinHeight }}>
<CodeEditor
value={value}
isExpand
noWrapper
language={CodeLanguage.json}
onChange={handleChange}
className='w-full'
placeholder={<div className='whitespace-pre'>{placeholder}</div>}
/>
</div>
)
}
</div>
)
}
export default FormItem

View File

@@ -0,0 +1,28 @@
import Empty from './empty'
import Item from './item'
import type {
LoopVariable,
LoopVariablesComponentShape,
} from '@/app/components/workflow/nodes/loop/types'
type LoopVariableProps = {
variables?: LoopVariable[]
} & LoopVariablesComponentShape
const LoopVariableComponent = ({
variables = [],
...restProps
}: LoopVariableProps) => {
if (!variables.length)
return <Empty />
return variables.map(variable => (
<Item
key={variable.id}
item={variable}
{...restProps}
/>
))
}
export default LoopVariableComponent

View File

@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import PureSelect from '@/app/components/base/select/pure'
type InputModeSelectProps = {
value?: string
onChange: (value: string) => void
}
const InputModeSelect = ({
value,
onChange,
}: InputModeSelectProps) => {
const { t } = useTranslation()
const options = [
{
label: 'Variable',
value: 'variable',
},
{
label: 'Constant',
value: 'constant',
},
]
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
title: t('workflow.nodes.loop.inputMode'),
className: 'w-[132px]',
}}
/>
)
}
export default InputModeSelect

View File

@@ -0,0 +1,78 @@
import { useCallback } from 'react'
import { RiDeleteBinLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import InputModeSelect from './input-mode-selec'
import VariableTypeSelect from './variable-type-select'
import FormItem from './form-item'
import ActionButton from '@/app/components/base/action-button'
import Input from '@/app/components/base/input'
import type {
LoopVariable,
LoopVariablesComponentShape,
} from '@/app/components/workflow/nodes/loop/types'
type ItemProps = {
item: LoopVariable
} & LoopVariablesComponentShape
const Item = ({
nodeId,
item,
handleRemoveLoopVariable,
handleUpdateLoopVariable,
}: ItemProps) => {
const { t } = useTranslation()
const handleUpdateItemLabel = useCallback((e: any) => {
handleUpdateLoopVariable(item.id, { label: e.target.value })
}, [item.id, handleUpdateLoopVariable])
const handleUpdateItemVarType = useCallback((value: any) => {
handleUpdateLoopVariable(item.id, { var_type: value, value: undefined })
}, [item.id, handleUpdateLoopVariable])
const handleUpdateItemValueType = useCallback((value: any) => {
handleUpdateLoopVariable(item.id, { value_type: value, value: undefined })
}, [item.id, handleUpdateLoopVariable])
const handleUpdateItemValue = useCallback((value: any) => {
handleUpdateLoopVariable(item.id, { value })
}, [item.id, handleUpdateLoopVariable])
return (
<div className='mb-4 flex last-of-type:mb-0'>
<div className='w-0 grow'>
<div className='mb-1 grid grid-cols-3 gap-1'>
<Input
value={item.label}
onChange={handleUpdateItemLabel}
autoFocus={!item.label}
placeholder={t('workflow.nodes.loop.variableName')}
/>
<VariableTypeSelect
value={item.var_type}
onChange={handleUpdateItemVarType}
/>
<InputModeSelect
value={item.value_type}
onChange={handleUpdateItemValueType}
/>
</div>
<div>
<FormItem
nodeId={nodeId}
item={item}
onChange={handleUpdateItemValue}
/>
</div>
</div>
<ActionButton
className='shrink-0'
size='l'
onClick={() => handleRemoveLoopVariable(item.id)}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
</ActionButton>
</div>
)
}
export default Item

View File

@@ -0,0 +1,51 @@
import PureSelect from '@/app/components/base/select/pure'
import { VarType } from '@/app/components/workflow/types'
type VariableTypeSelectProps = {
value?: string
onChange: (value: string) => void
}
const VariableTypeSelect = ({
value,
onChange,
}: VariableTypeSelectProps) => {
const options = [
{
label: 'String',
value: VarType.string,
},
{
label: 'Number',
value: VarType.number,
},
{
label: 'Object',
value: VarType.object,
},
{
label: 'Array[string]',
value: VarType.arrayString,
},
{
label: 'Array[number]',
value: VarType.arrayNumber,
},
{
label: 'Array[object]',
value: VarType.arrayObject,
},
]
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
className: 'w-[132px]',
}}
/>
)
}
export default VariableTypeSelect

View File

@@ -28,6 +28,11 @@ const nodeDefault: NodeDefault<LoopNodeType> = {
checkValid(payload: LoopNodeType, t: any) {
let errorMessages = ''
payload.loop_variables?.forEach((variable) => {
if (!variable.label)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
})
payload.break_conditions!.forEach((condition) => {
if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0))
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })

View File

@@ -1,13 +1,14 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
import Split from '../_base/components/split'
import ResultPanel from '../../run/result-panel'
import InputNumberWithSlider from '../_base/components/input-number-with-slider'
import type { LoopNodeType } from './types'
import useConfig from './use-config'
import ConditionWrap from './components/condition-wrap'
import LoopVariable from './components/loop-variables'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
@@ -45,6 +46,9 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({
handleUpdateSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
handleAddLoopVariable,
handleRemoveLoopVariable,
handleUpdateLoopVariable,
} = useConfig(id, data)
const nodeInfo = formatTracing(loopRunResult, t)[0]
@@ -53,6 +57,27 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({
return (
<div className='mt-2'>
<div>
<Field
title={<div className='pl-3'>{t('workflow.nodes.loop.loopVariables')}</div>}
operations={
<div
className='mr-4 flex h-5 w-5 cursor-pointer items-center justify-center'
onClick={handleAddLoopVariable}
>
<RiAddLine className='h-4 w-4 text-text-tertiary' />
</div>
}
>
<div className='px-4'>
<LoopVariable
variables={inputs.loop_variables}
nodeId={id}
handleRemoveLoopVariable={handleRemoveLoopVariable}
handleUpdateLoopVariable={handleUpdateLoopVariable}
/>
</div>
</Field>
<Split className='my-2' />
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.breakCondition`)}</div>}
tooltip={t(`${i18nPrefix}.breakConditionTip`)}
@@ -74,7 +99,7 @@ const Panel: FC<NodePanelProps<LoopNodeType>> = ({
logicalOperator={inputs.logical_operator!}
/>
</Field>
<Split />
<Split className='mt-2' />
<div className='mt-2'>
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.loopMaxCount`)}</div>}

View File

@@ -4,6 +4,7 @@ import type {
CommonNodeType,
ErrorHandleMode,
ValueSelector,
ValueType,
Var,
VarType,
} from '@/app/components/workflow/types'
@@ -65,6 +66,13 @@ export type handleRemoveSubVariableCondition = (conditionId: string, subConditio
export type HandleUpdateSubVariableCondition = (conditionId: string, subConditionId: string, newSubCondition: Condition) => void
export type HandleToggleSubVariableConditionLogicalOperator = (conditionId: string) => void
export type LoopVariable = {
id: string
label: string
var_type: VarType
value_type: ValueType
value: any
}
export type LoopNodeType = CommonNodeType & {
startNodeType?: BlockEnum
start_node_id: string
@@ -73,4 +81,14 @@ export type LoopNodeType = CommonNodeType & {
break_conditions?: Condition[]
loop_count: number
error_handle_mode: ErrorHandleMode // how to handle error in the iteration
loop_variables?: LoopVariable[]
}
export type HandleUpdateLoopVariable = (id: string, updateData: Partial<LoopVariable>) => void
export type HandleRemoveLoopVariable = (id: string) => void
export type LoopVariablesComponentShape = {
nodeId: string
handleRemoveLoopVariable: HandleRemoveLoopVariable
handleUpdateLoopVariable: HandleUpdateLoopVariable
}

View File

@@ -1,14 +1,17 @@
import { useCallback } from 'react'
import {
useCallback,
useRef,
} from 'react'
import produce from 'immer'
import { useBoolean } from 'ahooks'
import { uuid4 } from '@sentry/utils'
import { v4 as uuid4 } from 'uuid'
import {
useIsChatMode,
useIsNodeInLoop,
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { VarType } from '../../types'
import { ValueType, VarType } from '../../types'
import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar, toNodeOutputVars } from '../_base/components/variable/utils'
@@ -27,6 +30,11 @@ const useConfig = (id: string, payload: LoopNodeType) => {
const conversationVariables = useStore(s => s.conversationVariables)
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
const inputsRef = useRef(inputs)
const handleInputsChange = useCallback((newInputs: LoopNodeType) => {
inputsRef.current = newInputs
setInputs(newInputs)
}, [setInputs])
const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type)
@@ -35,7 +43,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
// output
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const beforeNodes = getBeforeNodesInSameBranch(id)
const loopChildrenNodes = getLoopNodeChildren(id)
const loopChildrenNodes = [{ id, data: payload } as any, ...getLoopNodeChildren(id)]
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables)
@@ -291,6 +299,43 @@ const useConfig = (id: string, payload: LoopNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const handleAddLoopVariable = useCallback(() => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
handleInputsChange(newInputs)
}, [handleInputsChange])
const handleRemoveLoopVariable = useCallback((id: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
handleInputsChange(newInputs)
}, [handleInputsChange])
const handleUpdateLoopVariable = useCallback((id: string, updateData: any) => {
const loopVariables = inputsRef.current.loop_variables || []
const index = loopVariables.findIndex(item => item.id === id)
const newInputs = produce(inputsRef.current, (draft) => {
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})
handleInputsChange(newInputs)
}, [handleInputsChange])
return {
readOnly,
inputs,
@@ -325,6 +370,9 @@ const useConfig = (id: string, payload: LoopNodeType) => {
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
changeErrorResponseMode,
handleAddLoopVariable,
handleRemoveLoopVariable,
handleUpdateLoopVariable,
}
}

View File

@@ -6,7 +6,10 @@ import type {
BlockEnum,
Node,
} from '../../types'
import { generateNewNode } from '../../utils'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import {
LOOP_PADDING,
NODES_INITIAL_DATA,
@@ -114,7 +117,7 @@ export const useNodeLoopInteractions = () => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...NODES_INITIAL_DATA[childNodeType],
...child.data,