Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-24 19:30:56 +08:00
95 changed files with 10275 additions and 2761 deletions

View File

@ -0,0 +1,216 @@
import type { LoopNodeType } from '../types'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import { createUuidModuleMock } from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from '../use-config.helpers'
const mockUuid = vi.hoisted(() => vi.fn())
vi.mock('uuid', () => createUuidModuleMock(() => mockUuid()))
const createInputs = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
logical_operator: LogicalOperator.and,
break_conditions: [],
loop_variables: [],
...overrides,
})
describe('loop use-config helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('canUseAsLoopInput', () => {
it.each([
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
])('should accept %s loop inputs', (type) => {
expect(canUseAsLoopInput({ type } as never)).toBe(true)
})
it('should reject non-array loop inputs', () => {
expect(canUseAsLoopInput({ type: VarType.string } as never)).toBe(false)
})
})
it('should update error handling, loop count and logical operators immutably', () => {
const inputs = createInputs()
const withMode = updateErrorHandleMode(inputs, ErrorHandleMode.ContinueOnError)
const withCount = updateLoopCount(withMode, 6)
const toggled = toggleConditionOperator(withCount)
const toggledBack = toggleConditionOperator(toggled)
expect(withMode.error_handle_mode).toBe(ErrorHandleMode.ContinueOnError)
expect(withCount.loop_count).toBe(6)
expect(toggled.logical_operator).toBe(LogicalOperator.or)
expect(toggledBack.logical_operator).toBe(LogicalOperator.and)
expect(inputs.error_handle_mode).toBe(ErrorHandleMode.Terminated)
expect(inputs.loop_count).toBe(3)
})
it('should add, update and remove break conditions for regular and file attributes', () => {
mockUuid
.mockReturnValueOnce('condition-1')
.mockReturnValueOnce('condition-2')
const withBooleanCondition = addBreakCondition({
inputs: createInputs({ break_conditions: undefined }),
valueSelector: ['tool-node', 'enabled'],
variable: { type: VarType.boolean },
isVarFileAttribute: false,
})
const withFileCondition = addBreakCondition({
inputs: withBooleanCondition,
valueSelector: ['tool-node', 'file', 'transfer_method'],
variable: { type: VarType.file },
isVarFileAttribute: true,
})
const updated = updateBreakCondition(withFileCondition, 'condition-2', {
id: 'condition-2',
varType: VarType.file,
key: 'transfer_method',
variable_selector: ['tool-node', 'file', 'transfer_method'],
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
})
const removed = removeBreakCondition(updated, 'condition-1')
expect(withBooleanCondition.break_conditions).toEqual([
expect.objectContaining({
id: 'condition-1',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: 'false',
}),
])
expect(withFileCondition.break_conditions?.[1]).toEqual(expect.objectContaining({
id: 'condition-2',
varType: VarType.file,
comparison_operator: ComparisonOperator.in,
value: '',
}))
expect(updated.break_conditions?.[1]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
}))
expect(removed.break_conditions).toEqual([
expect.objectContaining({ id: 'condition-2' }),
])
})
it('should manage nested sub-variable conditions and ignore missing targets', () => {
mockUuid
.mockReturnValueOnce('sub-condition-1')
.mockReturnValueOnce('sub-condition-2')
const inputs = createInputs({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
key: 'name',
variable_selector: ['tool-node', 'file'],
comparison_operator: ComparisonOperator.contains,
value: '',
}],
})
const untouched = addSubVariableCondition(inputs, 'missing-condition')
const withKeyedSubCondition = addSubVariableCondition(inputs, 'condition-1', 'transfer_method')
const withDefaultKeySubCondition = addSubVariableCondition(withKeyedSubCondition, 'condition-1')
const updated = updateSubVariableCondition(withDefaultKeySubCondition, 'condition-1', 'sub-condition-1', {
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
})
const toggled = toggleSubVariableConditionOperator(updated, 'condition-1')
const removed = removeSubVariableCondition(toggled, 'condition-1', 'sub-condition-1')
const unchangedAfterMissingRemove = removeSubVariableCondition(removed, 'missing-condition', 'sub-condition-2')
expect(untouched).toEqual(inputs)
expect(withKeyedSubCondition.break_conditions?.[0].sub_variable_condition).toEqual({
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.in,
value: '',
}],
})
expect(withDefaultKeySubCondition.break_conditions?.[0].sub_variable_condition?.conditions[1]).toEqual({
id: 'sub-condition-2',
key: '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
expect(updated.break_conditions?.[0].sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
}))
expect(toggled.break_conditions?.[0].sub_variable_condition?.logical_operator).toBe(LogicalOperator.or)
expect(removed.break_conditions?.[0].sub_variable_condition?.conditions).toEqual([
expect.objectContaining({ id: 'sub-condition-2' }),
])
expect(unchangedAfterMissingRemove).toEqual(removed)
})
it('should add, update and remove loop variables without mutating the source inputs', () => {
mockUuid.mockReturnValueOnce('loop-variable-1')
const inputs = createInputs({ loop_variables: undefined })
const added = addLoopVariable(inputs)
const updated = updateLoopVariable(added, 'loop-variable-1', {
label: 'Loop Value',
value_type: ValueType.variable,
value: ['tool-node', 'result'],
})
const unchanged = updateLoopVariable(updated, 'missing-loop-variable', { label: 'ignored' })
const removed = removeLoopVariable(unchanged, 'loop-variable-1')
expect(added.loop_variables).toEqual([{
id: 'loop-variable-1',
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
}])
expect(updated.loop_variables).toEqual([{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'result'],
}])
expect(unchanged).toEqual(updated)
expect(removed.loop_variables).toEqual([])
expect(inputs.loop_variables).toBeUndefined()
})
})

View File

@ -0,0 +1,221 @@
import type { LoopNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetLoopNodeChildren = vi.hoisted(() => vi.fn())
const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { conversationVariables: unknown[], dataSourceList: unknown[] }) => unknown) => selector({
conversationVariables: [],
dataSourceList: [],
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<LoopNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeOutputVars: () => [{ nodeId: 'child-node', title: 'Child', vars: [] }],
}))
vi.mock('../use-is-var-file-attribute', () => ({
__esModule: true,
default: () => ({
getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args),
}),
}))
const createPayload = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_id: 'loop-node',
logical_operator: LogicalOperator.and,
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
}],
loop_count: 3,
error_handle_mode: ErrorHandleMode.ContinueOnError,
loop_variables: [{
id: 'loop-var-1',
label: 'item',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'value',
}],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetLoopNodeChildren.mockReturnValue([])
mockGetIsVarFileAttribute.mockReturnValue(false)
})
it('should expose derived outputs and input variable filtering', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.childrenNodeVars).toEqual([{ nodeId: 'child-node', title: 'Child', vars: [] }])
expect(result.current.loopChildrenNodes).toHaveLength(1)
expect(result.current.filterInputVar({ type: VarType.arrayNumber } as never)).toBe(true)
expect(result.current.filterInputVar({ type: VarType.string } as never)).toBe(false)
})
it('should update error mode, break conditions and logical operators', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
result.current.changeErrorResponseMode({ value: ErrorHandleMode.Terminated })
result.current.handleAddCondition(['node-1', 'score'], { type: VarType.number } as never)
result.current.handleUpdateCondition('condition-1', {
id: 'condition-1',
varType: VarType.number,
variable_selector: ['node-1', 'score'],
comparison_operator: ComparisonOperator.largerThan,
value: '3',
})
result.current.handleRemoveCondition('condition-1')
result.current.handleToggleConditionLogicalOperator()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
error_handle_mode: ErrorHandleMode.Terminated,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
variable_selector: ['node-1', 'score'],
varType: VarType.number,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
varType: VarType.number,
comparison_operator: ComparisonOperator.largerThan,
value: '3',
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
logical_operator: LogicalOperator.or,
}))
})
it('should manage sub-variable conditions and loop variables', () => {
const payload = createPayload({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-1',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
}],
})
const { result } = renderHook(() => useConfig('loop-node', payload))
result.current.handleAddSubVariableCondition('condition-1', 'name')
result.current.handleUpdateSubVariableCondition('condition-1', 'sub-1', {
id: 'sub-1',
key: 'size',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '2',
})
result.current.handleRemoveSubVariableCondition('condition-1', 'sub-1')
result.current.handleToggleSubVariableConditionLogicalOperator('condition-1')
result.current.handleUpdateLoopCount(5)
result.current.handleAddLoopVariable()
result.current.handleRemoveLoopVariable('loop-var-1')
result.current.handleUpdateLoopVariable('loop-var-1', { label: 'updated' })
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
key: 'name',
}),
]),
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_count: 5,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: [
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
],
}))
})
})

View File

@ -0,0 +1,100 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from '../use-interactions.helpers'
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
})
describe('loop interaction helpers', () => {
it('calculates bounds and container resize from overflowing children', () => {
const children = [
createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }),
createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }),
]
const bounds = getContainerBounds(children as Node[])
expect(bounds.rightNode?.id).toBe('b')
expect(bounds.bottomNode?.id).toBe('b')
expect(getContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({
width: 186,
height: 110,
})
expect(getContainerResize(createNode({ width: 300, height: 300 }), bounds)).toEqual({
width: undefined,
height: undefined,
})
})
it('restricts loop positions only for loop children and filters loop-start nodes', () => {
const parent = createNode({ id: 'parent', width: 200, height: 180 })
expect(getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node)).toEqual({ x: undefined, y: undefined })
expect(getRestrictedLoopPosition(
createNode({
position: { x: -10, y: 160 },
width: 80,
height: 40,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 16, y: 120 })
expect(getRestrictedLoopPosition(
createNode({
position: { x: 180, y: -4 },
width: 40,
height: 30,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 144, y: 65 })
expect(getLoopChildren([
createNode({ id: 'child', parentId: 'loop-1' }),
createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }),
createNode({ id: 'other', parentId: 'other-loop' }),
] as Node[], 'loop-1').map(item => item.id)).toEqual(['child'])
})
it('builds copied loop children with derived title and loop metadata', () => {
const child = createNode({
id: 'child',
position: { x: 12, y: 24 },
positionAbsolute: { x: 12, y: 24 },
extent: 'parent',
data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true },
})
const result = buildLoopChildCopy({
child: child as Node,
childNodeType: BlockEnum.Code,
defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'],
nodesWithSameTypeCount: 2,
newNodeId: 'loop-2',
index: 3,
})
expect(result.newId).toBe('loop-23')
expect(result.params).toEqual(expect.objectContaining({
parentId: 'loop-2',
zIndex: 1002,
data: expect.objectContaining({
title: 'Code 3',
isInLoop: true,
loop_id: 'loop-2',
selected: false,
_isBundled: false,
}),
}))
})
})

View File

@ -0,0 +1,174 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import {
createLoopNode,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { LOOP_PADDING } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { useNodeLoopInteractions } from '../use-interactions'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockGenerateNewNode = vi.hoisted(() => vi.fn())
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
}
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Code]: {
defaultValue: {
title: 'Code',
},
},
},
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args),
getNodeCustomTypeByNodeDataType: () => 'custom',
}))
describe('useNodeLoopInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expand the loop node when children overflow the bounds', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopRerender('loop-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedNodes = mockSetNodes.mock.calls[0][0]
const updatedLoopNode = updatedNodes.find((node: Node) => node.id === 'loop-node')
expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right)
expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom)
})
it('should restrict dragging to the loop container padding', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 200,
height: 180,
data: { width: 200, height: 180 },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
const dragResult = result.current.handleNodeLoopChildDrag(createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: -10, y: -5 },
width: 80,
height: 60,
data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true },
}))
expect(dragResult.restrictPosition).toEqual({
x: LOOP_PADDING.left,
y: LOOP_PADDING.top,
})
})
it('should rerender the parent loop node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip loop rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy loop children and remap ids', () => {
mockGetNodes.mockReturnValue([
createLoopNode({ id: 'loop-node' }),
createNode({
id: 'child-node',
parentId: 'loop-node',
data: { type: BlockEnum.Code, title: 'Child', desc: '' },
}),
createNode({
id: 'same-type-node',
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
}),
])
mockGenerateNewNode.mockReturnValue({
newNode: createNode({
id: 'generated',
parentId: 'new-loop',
data: { type: BlockEnum.Code, title: 'Code 3', desc: '', isInLoop: true, loop_id: 'new-loop' },
}),
})
const { result } = renderHook(() => useNodeLoopInteractions())
const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { existing: 'mapped' })
expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'custom',
parentId: 'new-loop',
}))
expect(copyResult.copyChildren).toHaveLength(1)
expect(copyResult.newIdMapping).toEqual({
'existing': 'mapped',
'child-node': 'new-loopgeneratednew-loop0',
})
})
})

View File

@ -0,0 +1,241 @@
import type { InputVar, Node, Variable } from '../../../types'
import type { Condition } from '../types'
import { BlockEnum, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER } from '@/config'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCase,
getVarSelectorsFromCondition,
} from '../use-single-run-form-params.helpers'
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../_base/components/variable/utils', () => ({
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
desc: '',
type,
},
} as Node)
const createInputVar = (variable: string, label: InputVar['label'] = variable): InputVar => ({
type: InputVarType.textInput,
label,
variable,
required: false,
})
const createCondition = (overrides: Partial<Condition> = {}): Condition => ({
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-node', 'value'],
comparison_operator: ComparisonOperator.equal,
value: '',
...overrides,
})
describe('use-single-run-form-params helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should collect var selectors from conditions and nested cases', () => {
const nestedCondition = createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['start-node', 'answer'],
}),
],
},
})
expect(getVarSelectorsFromCondition(nestedCondition)).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
])
expect(getVarSelectorsFromCase({
logical_operator: LogicalOperator.or,
conditions: [
nestedCondition,
createCondition({
id: 'condition-2',
variable_selector: ['other-node', 'result'],
}),
],
})).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
['other-node', 'result'],
])
})
it('should copy input values and dedupe duplicate or invalid input vars', () => {
const source = {
question: 'hello',
retry: true,
}
const values = createInputVarValues(source)
const deduped = dedupeInputVars([
createInputVar('tool-node.value'),
createInputVar('tool-node.value'),
undefined as unknown as InputVar,
createInputVar('start-node.answer'),
])
expect(values).toEqual(source)
expect(values).not.toBe(source)
expect(deduped).toEqual([
createInputVar('tool-node.value'),
createInputVar('start-node.answer'),
])
})
it('should build used output vars and pass-to-server keys while filtering loop-local selectors', () => {
const startNode = createNode('start-node', 'Start Node', BlockEnum.Start)
const sysNode = createNode('sys', 'System', BlockEnum.Start)
const loopChildrenNodes = [
createNode('tool-a', 'Tool A'),
createNode('tool-b', 'Tool B'),
createNode('current-node', 'Current Node'),
createNode('inner-node', 'Inner Node'),
]
mockGetNodeUsedVars.mockImplementation((node: Node) => {
switch (node.id) {
case 'tool-a':
return [['sys', 'files']]
case 'tool-b':
return [['start-node', 'answer'], ['current-node', 'self'], ['inner-node', 'secret']]
default:
return []
}
})
mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => {
return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key'
})
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys')
const toVarInputs = vi.fn((variables: Variable[]) => variables.map(variable => createInputVar(
variable.variable,
variable.label as InputVar['label'],
)))
const result = buildUsedOutVars({
loopChildrenNodes,
currentNodeId: 'current-node',
canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes],
isNodeInLoop: nodeId => nodeId === 'inner-node',
toVarInputs,
})
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({
variable: 'sys.files',
label: {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
},
}),
expect.objectContaining({
variable: 'start-node.answer',
label: {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
},
}),
])
expect(result.usedOutVars).toEqual([
createInputVar('sys.files', {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
}),
createInputVar('start-node.answer', {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
}),
])
expect(result.allVarObject).toEqual({
[['sys.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files',
},
[['sys.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files_backup',
},
[['start-node.answer', 'tool-b', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'answer_key',
},
})
})
it('should derive dependent vars from payload and filter current node references', () => {
const dependentVars = getDependentVarsFromLoopPayload({
nodeId: 'loop-node',
usedOutVars: [
createInputVar('start-node.answer'),
createInputVar('loop-node.internal'),
],
breakConditions: [
createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['loop-node', 'ignored'],
}),
],
},
}),
],
loopVariables: [
{
id: 'loop-variable-1',
label: 'Loop Input',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'next'],
},
{
id: 'loop-variable-2',
label: 'Constant',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'plain-text',
},
],
})
expect(dependentVars).toEqual([
['start-node', 'answer'],
['tool-node', 'value'],
['tool-node', 'next'],
])
})
})

View File

@ -0,0 +1,216 @@
import type { InputVar, Node } from '../../../types'
import type { LoopNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { ComparisonOperator, LogicalOperator } from '../types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseIsNodeInLoop = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockFormatTracing = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../../hooks', () => ({
useIsNodeInLoop: (...args: unknown[]) => mockUseIsNodeInLoop(...args),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatTracing(...args),
}))
vi.mock('../../_base/components/variable/utils', () => ({
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createLoopNode = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
break_conditions: [],
loop_variables: [],
...overrides,
})
const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
type,
desc: '',
},
} as Node)
const createInputVar = (variable: string): InputVar => ({
type: InputVarType.textInput,
label: variable,
variable,
required: false,
})
const createRunTrace = (): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'loop-node',
node_type: BlockEnum.Loop,
title: 'Loop',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 2,
loop_index: 1,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsNodeInLoop.mockReturnValue({
isNodeInLoop: (nodeId: string) => nodeId === 'inner-node',
})
mockUseWorkflow.mockReturnValue({
getLoopNodeChildren: () => [
createVariableNode('tool-a', 'Tool A'),
createVariableNode('loop-node', 'Loop Node'),
createVariableNode('inner-node', 'Inner Node'),
],
getBeforeNodesInSameBranch: () => [
createVariableNode('start-node', 'Start Node', BlockEnum.Start),
],
})
mockGetNodeUsedVars.mockImplementation((node: Node) => {
if (node.id === 'tool-a')
return [['start-node', 'answer']]
if (node.id === 'loop-node')
return [['loop-node', 'item']]
if (node.id === 'inner-node')
return [['inner-node', 'secret']]
return []
})
mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockReturnValue(false)
mockFormatTracing.mockReturnValue([{
id: 'formatted-node',
execution_metadata: { loop_index: 9 },
}])
})
it('should build single-run forms and filter out loop-local variables', () => {
const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => variables.map(item => createInputVar(item.variable)))
const varSelectorsToVarInputs = vi.fn(() => [
createInputVar('tool-a.result'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode({
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-a', 'result'],
comparison_operator: ComparisonOperator.equal,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [],
},
}],
loop_variables: [{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['start-node', 'answer'],
}],
}),
runInputData: {
question: 'hello',
},
runResult: null as unknown as NodeTracing,
loopRunResult: [],
setRunInputData: vi.fn(),
toVarInputs,
varSelectorsToVarInputs,
}))
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({ variable: 'start-node.answer' }),
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([
createInputVar('start-node.answer'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
expect(result.current.forms[0].values).toEqual({ question: 'hello' })
expect(result.current.allVarObject).toEqual({
'start-node.answer@@@tool-a@@@0': {
inSingleRunPassedKey: 'passed_key',
},
})
expect(result.current.getDependentVars()).toEqual([
['start-node', 'answer'],
['tool-a', 'result'],
['start-node', 'answer'],
])
})
it('should forward onChange and merge tracing metadata into node info', () => {
const setRunInputData = vi.fn()
const runResult = createRunTrace()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode(),
runInputData: {},
runResult,
loopRunResult: [runResult],
setRunInputData,
toVarInputs: vi.fn(() => []),
varSelectorsToVarInputs: vi.fn(() => []),
}))
act(() => {
result.current.forms[0].onChange({ retry: true })
})
expect(setRunInputData).toHaveBeenCalledWith({ retry: true })
expect(mockFormatTracing).toHaveBeenCalledWith([runResult], expect.any(Function))
expect(result.current.nodeInfo).toEqual({
id: 'formatted-node',
execution_metadata: expect.objectContaining({
loop_index: 9,
}),
})
})
})

View File

@ -0,0 +1,171 @@
import type { ErrorHandleMode, Var } from '../../types'
import type { Condition, LoopNodeType, LoopVariable } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { ValueType, VarType } from '../../types'
import { LogicalOperator } from './types'
import { getOperators } from './utils'
export const canUseAsLoopInput = (variable: Var) => {
return [
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
].includes(variable.type)
}
export const updateErrorHandleMode = (
inputs: LoopNodeType,
mode: ErrorHandleMode,
) => produce(inputs, (draft) => {
draft.error_handle_mode = mode
})
export const addBreakCondition = ({
inputs,
valueSelector,
variable,
isVarFileAttribute,
}: {
inputs: LoopNodeType
valueSelector: string[]
variable: { type: VarType }
isVarFileAttribute: boolean
}) => produce(inputs, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions.push({
id: uuid4(),
varType: variable.type,
variable_selector: valueSelector,
comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: variable.type === VarType.boolean ? 'false' : '',
})
})
export const removeBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
export const updateBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, condition)
})
export const toggleConditionOperator = (inputs: LoopNodeType) => produce(inputs, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
export const addSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
key?: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const comparisonOperators = getOperators(VarType.string, { key: key || '' })
condition.sub_variable_condition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: comparisonOperators[0],
value: '',
})
})
export const removeSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition?.sub_variable_condition)
return
condition.sub_variable_condition.conditions = condition.sub_variable_condition.conditions
.filter(item => item.id !== subConditionId)
})
export const updateSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, condition)
})
export const toggleSubVariableConditionOperator = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition?.sub_variable_condition) {
targetCondition.sub_variable_condition.logical_operator
= targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
}
})
export const updateLoopCount = (
inputs: LoopNodeType,
value: number,
) => produce(inputs, (draft) => {
draft.loop_count = value
})
export const addLoopVariable = (inputs: LoopNodeType) => produce(inputs, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
export const removeLoopVariable = (
inputs: LoopNodeType,
id: string,
) => produce(inputs, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
export const updateLoopVariable = (
inputs: LoopNodeType,
id: string,
updateData: Partial<LoopVariable>,
) => produce(inputs, (draft) => {
const index = draft.loop_variables?.findIndex(item => item.id === id) ?? -1
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})

View File

@ -9,13 +9,11 @@ import type {
HandleUpdateSubVariableCondition,
LoopNodeType,
} from './types'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useRef,
} from 'react'
import { v4 as uuid4 } from 'uuid'
import { useStore } from '@/app/components/workflow/store'
import {
useAllBuiltInTools,
@ -28,12 +26,25 @@ import {
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { ValueType, VarType } from '../../types'
import { toNodeOutputVars } from '../_base/components/variable/utils'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { LogicalOperator } from './types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from './use-config.helpers'
import useIsVarFileAttribute from './use-is-var-file-attribute'
import { getOperators } from './utils'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -50,9 +61,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
setInputs(newInputs)
}, [setInputs])
const filterInputVar = useCallback((varPayload: Var) => {
return ([VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile] as VarType[]).includes(varPayload.type)
}, [])
const filterInputVar = useCallback((varPayload: Var) => canUseAsLoopInput(varPayload), [])
// output
const { getLoopNodeChildren } = useWorkflow()
@ -78,158 +87,60 @@ const useConfig = (id: string, payload: LoopNodeType) => {
})
const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})
handleInputsChange(newInputs)
}, [inputs, handleInputsChange])
handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode))
}, [handleInputsChange])
const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions?.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: varItem.type === VarType.boolean ? 'false' : '',
})
})
handleInputsChange(newInputs)
handleInputsChange(addBreakCondition({
inputs: inputsRef.current,
valueSelector,
variable: varItem,
isVarFileAttribute: !!getIsVarFileAttribute(valueSelector),
}))
}, [getIsVarFileAttribute, handleInputsChange])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeBreakCondition(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
})
handleInputsChange(newInputs)
handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition))
}, [handleInputsChange])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleConditionOperator(inputsRef.current))
}, [handleInputsChange])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
value: '',
})
}
})
handleInputsChange(newInputs)
handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key))
}, [handleInputsChange])
const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId))
}, [handleInputsChange])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
})
handleInputsChange(newInputs)
handleInputsChange(updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition))
}, [handleInputsChange])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateLoopCount = useCallback((value: number) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_count = value
})
handleInputsChange(newInputs)
handleInputsChange(updateLoopCount(inputsRef.current, value))
}, [handleInputsChange])
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(addLoopVariable(inputsRef.current))
}, [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(removeLoopVariable(inputsRef.current, id))
}, [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(updateLoopVariable(inputsRef.current, id, updateData))
}, [handleInputsChange])
return {

View File

@ -0,0 +1,109 @@
import type {
BlockEnum,
Node,
} from '../../types'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
type ContainerBounds = {
rightNode?: Node
bottomNode?: Node
}
export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => {
return childrenNodes.reduce<ContainerBounds>((acc, node) => {
const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width!
? node
: acc.rightNode
const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height!
? node
: acc.bottomNode
return {
rightNode: nextRightNode,
bottomNode: nextBottomNode,
}
}, {})
}
export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) => {
const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width!
? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right
: undefined
const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height!
? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom
: undefined
return {
width,
height,
}
}
export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => {
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (!node.data.isInLoop || !parentNode)
return restrictPosition
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode.height! - LOOP_PADDING.bottom - node.height!
return restrictPosition
}
export const getLoopChildren = (nodes: Node[], nodeId: string) => {
return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE)
}
export const buildLoopChildCopy = ({
child,
childNodeType,
defaultValue,
nodesWithSameTypeCount,
newNodeId,
index,
}: {
child: Node
childNodeType: BlockEnum
defaultValue: Node['data']
nodesWithSameTypeCount: number
newNodeId: string
index: number
}) => {
const params = {
type: child.type!,
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameTypeCount > 0 ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
}
return {
params,
newId: `${newNodeId}${index}`,
}
}

View File

@ -6,15 +6,17 @@ import { produce } from 'immer'
import { useCallback } from 'react'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from './use-interactions.helpers'
export const useNodeLoopInteractions = () => {
const collaborativeWorkflow = useCollaborativeWorkflow()
@ -23,43 +25,20 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node
const childrenNodes = getLoopChildren(nodes, nodeId)
const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes))
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) {
if (resize.width || resize.height) {
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 (resize.width) {
n.data.width = resize.width
n.width = resize.width
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
if (resize.height) {
n.data.height = resize.height
n.height = resize.height
}
}
})
@ -72,25 +51,8 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopChildDrag = useCallback((node: Node) => {
const { nodes } = collaborativeWorkflow.getState()
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (node.data.isInLoop) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
restrictPosition: getRestrictedLoopPosition(node, nodes.find(n => n.id === node.parentId)),
}
}, [collaborativeWorkflow])
@ -105,35 +67,26 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { nodes } = collaborativeWorkflow.getState()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
const childrenNodes = getLoopChildren(nodes, nodeId)
const newIdMapping = { ...idMapping }
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
const childCopy = buildLoopChildCopy({
child,
childNodeType,
defaultValue: defaultValue as Node['data'],
nodesWithSameTypeCount: nodesWithSameType.length,
newNodeId,
index,
})
newNode.id = `${newNodeId}${newNode.id + index}`
const { newNode } = generateNewNode({
...childCopy.params,
type: getNodeCustomTypeByNodeDataType(childNodeType),
})
newNode.id = `${newNodeId}${newNode.id + childCopy.newId}`
newIdMapping[child.id] = newNode.id
return newNode
})

View File

@ -0,0 +1,131 @@
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopVariable } from './types'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] {
const vars: ValueSelector[] = []
caseItem.conditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
return vars
}
export function getVarSelectorsFromCondition(condition: Condition): ValueSelector[] {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition?.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
export const createInputVarValues = (runInputData: Record<string, unknown>) => {
const vars: Record<string, unknown> = {}
Object.keys(runInputData).forEach((key) => {
vars[key] = runInputData[key]
})
return vars
}
export const dedupeInputVars = (inputVars: InputVar[]) => {
const seen: Record<string, boolean> = {}
const uniqueInputVars: InputVar[] = []
inputVars.forEach((input) => {
if (!input || seen[input.variable])
return
seen[input.variable] = true
uniqueInputVars.push(input)
})
return uniqueInputVars
}
export const buildUsedOutVars = ({
loopChildrenNodes,
currentNodeId,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}: {
loopChildrenNodes: Node[]
currentNodeId: string
canChooseVarNodes: Node[]
isNodeInLoop: (nodeId: string) => boolean
toVarInputs: (variables: Variable[]) => InputVar[]
}) => {
const vars: ValueSelector[] = []
const seenVarSelectors: Record<string, boolean> = {}
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === currentNodeId)
return
if (isNodeInLoop(varSelector[0]))
return
const varSelectorStr = varSelector.join('.')
if (!seenVarSelectors[varSelectorStr]) {
seenVarSelectors[varSelectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSelectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const usedOutVars = toVarInputs(vars.map((valueSelector) => {
const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title,
variable: isSystemVar(valueSelector) ? valueSelector.join('.') : valueSelector[valueSelector.length - 1],
},
variable: valueSelector.join('.'),
value_selector: valueSelector,
}
}))
return { usedOutVars, allVarObject }
}
export const getDependentVarsFromLoopPayload = ({
nodeId,
usedOutVars,
breakConditions,
loopVariables,
}: {
nodeId: string
usedOutVars: InputVar[]
breakConditions?: Condition[]
loopVariables?: LoopVariable[]
}) => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
breakConditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
loopVariables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
return vars.filter(item => item[0] !== nodeId)
}

View File

@ -1,15 +1,19 @@
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopNodeType } from './types'
import type { LoopNodeType } from './types'
import type { NodeTracing } from '@/types/workflow'
import { useCallback, useMemo } from 'react'
import { useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { WorkflowContext } from '@/app/components/workflow/context'
import formatTracing from '@/app/components/workflow/run/utils/format-log'
import { useStore } from '@/app/components/workflow/store'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { useIsNodeInLoop, useWorkflow } from '../../hooks'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCondition,
} from './use-single-run-form-params.helpers'
type Params = {
id: string
@ -37,9 +41,10 @@ const useSingleRunFormParams = ({
const { isNodeInLoop } = useIsNodeInLoop(id)
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const parentAvailableNodes = useStore(useShallow(s => s.parentAvailableNodes)) || []
const workflowStore = useContext(WorkflowContext)
const parentAvailableNodes = workflowStore?.getState().parentAvailableNodes || []
const loopChildrenNodes = getLoopNodeChildren(id)
const beforeNodes = (() => {
const beforeNodes = useMemo(() => {
const baseBeforeNodes = getBeforeNodesInSameBranch(id)
if (!parentAvailableNodes.length)
return baseBeforeNodes
@ -52,59 +57,16 @@ const useSingleRunFormParams = ({
merged.set(node.id, node)
})
return Array.from(merged.values())
})()
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
}, [getBeforeNodesInSameBranch, id, parentAvailableNodes])
const canChooseVarNodes = useMemo(() => [...beforeNodes, ...loopChildrenNodes], [beforeNodes, loopChildrenNodes])
const { usedOutVars, allVarObject } = (() => {
const vars: ValueSelector[] = []
const varObjs: Record<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) { // skip loop node itself variable: item, index
return
}
const isInLoop = isNodeInLoop(varSelector[0])
if (isInLoop) // not pass loop inner variable
return
const varSectorStr = varSelector.join('.')
if (!varObjs[varSectorStr]) {
varObjs[varSectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const res = toVarInputs(vars.map((item) => {
const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `${item.join('.')}`,
value_selector: item,
}
}))
return {
usedOutVars: res,
allVarObject,
}
})()
const { usedOutVars, allVarObject } = useMemo(() => buildUsedOutVars({
loopChildrenNodes,
currentNodeId: id,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}), [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs])
const nodeInfo = useMemo(() => {
const formattedNodeInfo = formatTracing(loopRunResult, t)[0]
@ -126,38 +88,9 @@ const useSingleRunFormParams = ({
setRunInputData(newPayload)
}, [setRunInputData])
const inputVarValues = (() => {
const vars: Record<string, any> = {}
Object.keys(runInputData)
.forEach((key) => {
vars[key] = runInputData[key]
})
return vars
})()
const inputVarValues = useMemo(() => createInputVarValues(runInputData), [runInputData])
const getVarSelectorsFromCase = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarSelectorsFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarSelectorsFromCondition = (condition: Condition) => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
const forms = (() => {
const forms = useMemo(() => {
const allInputs: ValueSelector[] = []
payload.break_conditions?.forEach((condition) => {
const vars = getVarSelectorsFromCondition(condition)
@ -170,16 +103,7 @@ const useSingleRunFormParams = ({
})
const inputVarsFromValue: InputVar[] = []
const varInputs = [...varSelectorsToVarInputs(allInputs), ...inputVarsFromValue]
const existVarsKey: Record<string, boolean> = {}
const uniqueVarInputs: InputVar[] = []
varInputs.forEach((input) => {
if (!input)
return
if (!existVarsKey[input.variable]) {
existVarsKey[input.variable] = true
uniqueVarInputs.push(input)
}
})
const uniqueVarInputs = dedupeInputVars(varInputs)
return [
{
inputs: [...usedOutVars, ...uniqueVarInputs],
@ -187,43 +111,14 @@ const useSingleRunFormParams = ({
onChange: setInputVarValues,
},
]
})()
}, [payload.break_conditions, payload.loop_variables, varSelectorsToVarInputs, usedOutVars, inputVarValues, setInputVarValues])
const getVarFromCaseItem = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarFromCondition = (condition: Condition): ValueSelector[] => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarFromCaseItem(condition.sub_variable_condition))
return vars
}
const getDependentVars = () => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
payload.break_conditions?.forEach((condition) => {
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
payload.loop_variables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
const hasFilterLoopVars = vars.filter(item => item[0] !== id)
return hasFilterLoopVars
}
const getDependentVars = useCallback(() => getDependentVarsFromLoopPayload({
nodeId: id,
usedOutVars,
breakConditions: payload.break_conditions,
loopVariables: payload.loop_variables,
}), [id, usedOutVars, payload.break_conditions, payload.loop_variables])
return {
forms,