From 6cfb5985ebd9419158064bd8c70227bc2be65342 Mon Sep 17 00:00:00 2001 From: heheer Date: Tue, 10 Feb 2026 17:29:32 +0800 Subject: [PATCH 1/4] update var node support number and boolean --- .../system/variableUpdate/constants.ts | 14 + .../template/system/variableUpdate/type.d.ts | 10 +- packages/global/core/workflow/utils.ts | 12 +- .../workflow/dispatch/tools/runUpdateVar.ts | 71 ++- packages/web/i18n/en/workflow.json | 7 + packages/web/i18n/zh-CN/workflow.json | 7 + packages/web/i18n/zh-Hant/workflow.json | 7 + .../components/core/app/formRender/index.tsx | 4 +- .../Flow/nodes/NodeVariableUpdate.tsx | 323 ++++++---- .../RenderInput/templates/Reference.tsx | 7 +- .../workflow/dispatch/runUpdateVar.test.ts | 561 ++++++++++++++++++ 11 files changed, 884 insertions(+), 139 deletions(-) create mode 100644 packages/global/core/workflow/template/system/variableUpdate/constants.ts create mode 100644 test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts diff --git a/packages/global/core/workflow/template/system/variableUpdate/constants.ts b/packages/global/core/workflow/template/system/variableUpdate/constants.ts new file mode 100644 index 000000000000..a575841c31c3 --- /dev/null +++ b/packages/global/core/workflow/template/system/variableUpdate/constants.ts @@ -0,0 +1,14 @@ +export enum VariableUpdateOperatorEnum { + set = 'set', + add = 'add', + sub = 'sub', + mul = 'mul', + div = 'div', + negate = 'negate' +} + +export const BooleanSelectValueEnum = { + setTrue: `${VariableUpdateOperatorEnum.set}:true`, + setFalse: `${VariableUpdateOperatorEnum.set}:false`, + negate: VariableUpdateOperatorEnum.negate +}; diff --git a/packages/global/core/workflow/template/system/variableUpdate/type.d.ts b/packages/global/core/workflow/template/system/variableUpdate/type.d.ts index 1468faedac17..84445618a0f4 100644 --- a/packages/global/core/workflow/template/system/variableUpdate/type.d.ts +++ b/packages/global/core/workflow/template/system/variableUpdate/type.d.ts @@ -1,10 +1,16 @@ import type { FlowNodeInputTypeEnum } from '../../../node/constant'; -import type { ReferenceItemValueType, ReferenceValueType } from '../../..//type/io'; +import type { ReferenceItemValueType } from '../../../type/io'; import type { WorkflowIOValueTypeEnum } from '../../../constants'; +import type { VariableUpdateOperatorEnum } from './constants'; + +export type TOperationValue = { + operator: VariableUpdateOperatorEnum; + value?: number | boolean | string; +}; export type TUpdateListItem = { variable?: ReferenceItemValueType; - value?: ReferenceValueType; // input: ['',value], reference: [nodeId,outputId] + value?: ReferenceItemValueType | [string, string | TOperationValue]; valueType?: WorkflowIOValueTypeEnum; renderType: FlowNodeInputTypeEnum.input | FlowNodeInputTypeEnum.reference; }; diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 4e420c384723..05661a2e3902 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -339,10 +339,14 @@ export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] } export const formatEditorVariablePickerIcon = ( variables: { key: string; label: string; type?: `${VariableInputEnum}`; required?: boolean }[] ): EditorVariablePickerType[] => { - return variables.map((item) => ({ - ...item, - icon: item.type ? variableMap[item.type]?.icon : variableMap['input'].icon - })); + return variables.map((item) => { + const config = item.type ? variableMap[item.type] : variableMap['input']; + return { + ...item, + icon: config?.icon, + valueType: config?.defaultValueType + }; + }); }; // Check the value is a valid reference value format: [variableId, outputId] diff --git a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts index c0fc0373956a..e3a5ea32e5ed 100644 --- a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts +++ b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts @@ -1,5 +1,5 @@ import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants'; +import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum, SseResponseEventEnum @@ -10,10 +10,23 @@ import { replaceEditorVariable } from '@fastgpt/global/core/workflow/runtime/utils'; import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import type { ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; import { type ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import { runtimeSystemVar2StoreType } from '../utils'; import { isValidReferenceValue } from '@fastgpt/global/core/workflow/utils'; import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils'; +import { VariableUpdateOperatorEnum } from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; +import type { TOperationValue } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; + +const operatorHandlerMap: Record any> = { + [VariableUpdateOperatorEnum.set]: (_cur, operand) => operand, + [VariableUpdateOperatorEnum.add]: (cur, operand) => (Number(cur) || 0) + operand, + [VariableUpdateOperatorEnum.sub]: (cur, operand) => (Number(cur) || 0) - operand, + [VariableUpdateOperatorEnum.mul]: (cur, operand) => (Number(cur) || 0) * operand, + [VariableUpdateOperatorEnum.div]: (cur, operand) => + operand !== 0 ? (Number(cur) || 0) / operand : Number(cur) || 0, + [VariableUpdateOperatorEnum.negate]: (cur) => !cur +}; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.updateList]: TUpdateListItem[]; @@ -49,33 +62,55 @@ export const dispatchUpdateVariable = async (props: Props): Promise => } const value = (() => { - // If first item is empty, it means it is a input value - if (!item.value?.[0]) { - const val = - typeof item.value?.[1] === 'string' - ? replaceEditorVariable({ - text: item.value?.[1], - nodes: runtimeNodes, - variables - }) - : item.value?.[1]; - - return valueTypeFormat(val, item.valueType); - } else { + if (item.value?.[0]) { return getReferenceVariableValue({ - value: item.value, + value: item.value as ReferenceValueType, variables, nodes: runtimeNodes }); } + + const rawVal = item.value?.[1]; + + if (typeof rawVal === 'object' && rawVal !== null && 'operator' in rawVal) { + const { operator, value: operand } = rawVal as TOperationValue; + const handler = operatorHandlerMap[operator]; + if (!handler) return operand ?? null; + + const currentValue = getReferenceVariableValue({ + value: variable, + variables, + nodes: runtimeNodes + }); + + if (operator === VariableUpdateOperatorEnum.set) { + if (item.valueType === WorkflowIOValueTypeEnum.number) { + if (operand === '' || operand === undefined || operand === null) return null; + return Number(operand); + } + return valueTypeFormat(operand, item.valueType); + } + + const numOperand = Number(operand); + if (operator !== VariableUpdateOperatorEnum.negate && isNaN(numOperand)) { + return currentValue; + } + + const typedCurrentValue = valueTypeFormat(currentValue, item.valueType) ?? currentValue; + return handler(typedCurrentValue, numOperand); + } + + const val = + typeof rawVal === 'string' + ? replaceEditorVariable({ text: rawVal, nodes: runtimeNodes, variables }) + : rawVal; + + return valueTypeFormat(val, item.valueType); })(); - // Update node output - // Global variable if (varNodeId === VARIABLE_NODE_ID) { variables[varKey] = value; } else { - // Other nodes runtimeNodes .find((node) => node.nodeId === varNodeId) ?.outputs?.find((output) => { diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index ca3991ee4c15..888c62841532 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -228,6 +228,13 @@ "variable_description": "Variable description", "variable_picker_tips": "Type node name or variable name to search", "variable_update": "Variable Update", + "variable_update_operator_set": "Set", + "variable_update_operator_add": "Add", + "variable_update_operator_sub": "Subtract", + "variable_update_operator_mul": "Multiply", + "variable_update_operator_div": "Divide", + "variable_update_number_placeholder": "Enter value", + "variable_update_boolean_negate": "Negate", "workflow.My edit": "My Edit", "workflow.Switch_success": "Switch Successful", "workflow.Team cloud": "Team Cloud", diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index 3c815f06af8d..117cc78e81b1 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -228,6 +228,13 @@ "variable_description": "变量描述", "variable_picker_tips": "可输入节点名或变量名搜索", "variable_update": "变量更新", + "variable_update_operator_set": "等于", + "variable_update_operator_add": "加", + "variable_update_operator_sub": "减", + "variable_update_operator_mul": "乘", + "variable_update_operator_div": "除", + "variable_update_number_placeholder": "输入值", + "variable_update_boolean_negate": "取反", "workflow.My edit": "我的编辑", "workflow.Switch_success": "切换成功", "workflow.Team cloud": "团队云端", diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index 513c5f4f5987..a80e37a0d9da 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -228,6 +228,13 @@ "variable_description": "變數描述", "variable_picker_tips": "可以輸入節點名稱或變數名稱搜尋", "variable_update": "變數更新", + "variable_update_operator_set": "等於", + "variable_update_operator_add": "加", + "variable_update_operator_sub": "減", + "variable_update_operator_mul": "乘", + "variable_update_operator_div": "除", + "variable_update_number_placeholder": "輸入值", + "variable_update_boolean_negate": "取反", "workflow.My edit": "我的編輯", "workflow.Switch_success": "切換成功", "workflow.Team cloud": "團隊雲端", diff --git a/projects/app/src/components/core/app/formRender/index.tsx b/projects/app/src/components/core/app/formRender/index.tsx index 0da6ddf82b39..1af2c23ecac5 100644 --- a/projects/app/src/components/core/app/formRender/index.tsx +++ b/projects/app/src/components/core/app/formRender/index.tsx @@ -69,7 +69,7 @@ const InputRender = (props: InputRenderProps) => { variableLabels={props.variableLabels} title={props.title} maxLength={props.maxLength} - minH={40} + minH={Number(props.minH) || 40} maxH={120} isRichText={props.isRichText} /> @@ -85,7 +85,7 @@ const InputRender = (props: InputRenderProps) => { variableLabels={props.variableLabels} title={props.title} maxLength={props.maxLength} - minH={100} + minH={Number(props.minH) || 100} maxH={300} ExtensionPopover={props.ExtensionPopover} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx index 4aebabb5c6f5..06093007e79a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx @@ -1,25 +1,22 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import NodeCard from './render/NodeCard'; import { type NodeProps } from 'reactflow'; import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { useTranslation } from 'next-i18next'; import { Box, Button, Flex } from '@chakra-ui/react'; +import NodeInputSelect from '@fastgpt/web/components/core/workflow/NodeInputSelect'; import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; -import type { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { + WorkflowIOValueTypeEnum, NodeInputKeyEnum, VARIABLE_NODE_ID, VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; import { useContextSelector } from 'use-context-selector'; -import { - FlowNodeInputMap, - FlowNodeInputTypeEnum -} from '@fastgpt/global/core/workflow/node/constant'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import Container from '../components/Container'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { SmallAddIcon } from '@chakra-ui/icons'; -import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { type ReferenceItemValueType, type ReferenceValueType @@ -39,6 +36,26 @@ import { InputTypeEnum } from '@/components/core/app/formRender/constant'; import { WorkflowActionsContext } from '../../context/workflowActionsContext'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoizedFn } from 'ahooks'; +import { + BooleanSelectValueEnum, + VariableUpdateOperatorEnum +} from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; +import type { TOperationValue } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; +import ValueTypeLabel from './render/ValueTypeLabel'; + +const getInputInitialValue = ( + valueType?: WorkflowIOValueTypeEnum +): TUpdateListItem['value'] | undefined => { + if (valueType === WorkflowIOValueTypeEnum.number) { + return ['', { operator: VariableUpdateOperatorEnum.set, value: '' }]; + } + if (valueType === WorkflowIOValueTypeEnum.boolean) { + return ['', { operator: VariableUpdateOperatorEnum.set, value: true }]; + } + return undefined; +}; const NodeVariableUpdate = ({ data, selected }: NodeProps) => { const { inputs = [], nodeId } = data; @@ -51,19 +68,6 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => ); const appDetail = useContextSelector(AppContext, (v) => v.appDetail); - const menuList = useRef([ - { - renderType: FlowNodeInputTypeEnum.input, - icon: FlowNodeInputMap[FlowNodeInputTypeEnum.input].icon, - label: t('common:core.workflow.inputType.Manual input') - }, - { - renderType: FlowNodeInputTypeEnum.reference, - icon: FlowNodeInputMap[FlowNodeInputTypeEnum.reference].icon, - label: t('common:core.workflow.inputType.Reference') - } - ]); - const variables = useMemoEnhance(() => { return getEditorVariables({ nodeId, @@ -92,6 +96,41 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => [inputs] ); + const numberOperatorList = useMemo( + () => [ + { + label: t('workflow:variable_update_operator_set'), + value: VariableUpdateOperatorEnum.set + }, + { + label: t('workflow:variable_update_operator_add'), + value: VariableUpdateOperatorEnum.add + }, + { + label: t('workflow:variable_update_operator_sub'), + value: VariableUpdateOperatorEnum.sub + }, + { + label: t('workflow:variable_update_operator_mul'), + value: VariableUpdateOperatorEnum.mul + }, + { + label: t('workflow:variable_update_operator_div'), + value: VariableUpdateOperatorEnum.div + } + ], + [t] + ); + + const booleanOperatorList = useMemo( + () => [ + { label: 'True', value: BooleanSelectValueEnum.setTrue }, + { label: 'False', value: BooleanSelectValueEnum.setFalse }, + { label: t('workflow:variable_update_boolean_negate'), value: BooleanSelectValueEnum.negate } + ], + [t] + ); + const onUpdateList = useCallback( (value: TUpdateListItem[]) => { const updateListInput = inputs.find((input) => input.key === NodeInputKeyEnum.updateList); @@ -173,52 +212,26 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => systemConfigNode, chatConfig: appDetail.chatConfig }); - const renderTypeData = menuList.current.find( - (item) => item.renderType === updateItem.renderType - ); - const onUpdateNewValue = (value: any) => { - if (updateItem.renderType === FlowNodeInputTypeEnum.reference) { - onUpdateList( - updateList.map((update, i) => (i === index ? { ...update, value: value } : update)) - ); - } else { - onUpdateList( - updateList.map((update, i) => - i === index ? { ...update, value: ['', value] } : update - ) - ); - } + onUpdateList( + updateList.map((item, i) => + i === index + ? { + ...item, + value: + updateItem.renderType === FlowNodeInputTypeEnum.reference ? value : ['', value] + } + : item + ) + ); }; return ( - {t('common:core.workflow.variable')} - { - onUpdateList( - updateList.map((update, i) => { - if (i === index) { - return { - ...update, - value: ['', ''], - valueType: getRefData({ - variable: value as ReferenceItemValueType, - getNodeById, - systemConfigNode, - chatConfig: appDetail.chatConfig - }).valueType, - variable: value as ReferenceItemValueType - }; - } - return update; - }) - ); - }} - /> + + {t('common:core.workflow.variable')} + {updateList.length > 1 && ( ) => /> )} - - - {t('common:value')} - item.renderType === updateItem.renderType)?.label - } - > - - - + : item + ) + ); + }} + labelSuffix={valueType ? : undefined} + /> + + + + + {t('common:value')} + + + { + const isReference = e === FlowNodeInputTypeEnum.reference; + onUpdateList( + updateList.map((item, i) => + i === index + ? { + ...item, + value: isReference ? undefined : getInputInitialValue(valueType), + renderType: isReference + ? FlowNodeInputTypeEnum.reference + : FlowNodeInputTypeEnum.input + } + : item + ) + ); + }} + /> + + - {/* Render input components */} + {(() => { if (updateItem.renderType === FlowNodeInputTypeEnum.reference) { return ( ); } - return ( - - + onUpdateNewValue({ ...opValue, operator: op })} + /> + + onUpdateNewValue({ ...opValue, value: val }) + } + /> + + ); + } + + if (valueType === WorkflowIOValueTypeEnum.boolean) { + const raw = updateItem.value?.[1]; + const opValue: TOperationValue = + typeof raw === 'object' && raw !== null && 'operator' in raw + ? (raw as TOperationValue) + : { operator: VariableUpdateOperatorEnum.set, value: !!raw }; + + const selectValue = + opValue.operator === VariableUpdateOperatorEnum.negate + ? BooleanSelectValueEnum.negate + : opValue.value === false + ? BooleanSelectValueEnum.setFalse + : BooleanSelectValueEnum.setTrue; + + return ( + { + if (val === BooleanSelectValueEnum.negate) { + onUpdateNewValue({ + operator: VariableUpdateOperatorEnum.negate + }); + } else { + onUpdateNewValue({ + operator: VariableUpdateOperatorEnum.set, + value: val === BooleanSelectValueEnum.setTrue + }); + } + }} /> - + ); + } + + return ( + ); })()} - + ); } @@ -309,7 +407,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const Render = useMemo(() => { return ( - + {updateList.map((updateItem, index) => ( @@ -356,12 +454,14 @@ const VariableSelector = ({ nodeId, variable, valueType, - onSelect + onSelect, + labelSuffix }: { nodeId: string; variable?: ReferenceValueType; valueType?: WorkflowIOValueTypeEnum; onSelect: (e?: ReferenceValueType) => void; + labelSuffix?: React.ReactNode; }) => { const { t } = useTranslation(); @@ -377,6 +477,7 @@ const VariableSelector = ({ value={variable} onSelect={onSelect} isArray={valueType?.includes('array')} + labelSuffix={labelSuffix} /> ); }; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 595bfaac2dd7..30e979014700 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -52,6 +52,7 @@ type CommonSelectProps = { }[]; popDirection?: 'top' | 'bottom'; ButtonProps?: ButtonProps; + labelSuffix?: React.ReactNode; }; type SelectProps = CommonSelectProps & { isArray?: T; @@ -175,7 +176,8 @@ const SingleReferenceSelector = ({ list = [], onSelect, popDirection, - ButtonProps + ButtonProps, + labelSuffix }: SelectProps) => { const getSelectValue = useCallback( (value: ReferenceValueType) => { @@ -221,6 +223,7 @@ const SingleReferenceSelector = ({ {nodeName} {outputName} + {labelSuffix} ) : ( @@ -235,7 +238,7 @@ const SingleReferenceSelector = ({ ButtonProps={ButtonProps} /> ); - }, [ButtonProps, getSelectValue, list, onSelect, placeholder, popDirection, value]); + }, [ButtonProps, getSelectValue, labelSuffix, list, onSelect, placeholder, popDirection, value]); return ItemSelector; }; diff --git a/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts b/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts new file mode 100644 index 000000000000..fb0977e7733c --- /dev/null +++ b/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts @@ -0,0 +1,561 @@ +import { describe, it, expect, vi } from 'vitest'; +import { dispatchUpdateVariable } from '@fastgpt/service/core/workflow/dispatch/tools/runUpdateVar'; +import { + VARIABLE_NODE_ID, + WorkflowIOValueTypeEnum, + NodeInputKeyEnum +} from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { VariableUpdateOperatorEnum } from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; +import type { TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; + +/** 构造最小 mock props */ +const createMockProps = ({ + updateList, + variables = {}, + runtimeNodes = [] +}: { + updateList: TUpdateListItem[]; + variables?: Record; + runtimeNodes?: Partial[]; +}) => { + const allNodes = [ + { + nodeId: VARIABLE_NODE_ID, + name: 'Variables', + inputs: [], + outputs: [] + }, + ...runtimeNodes + ] as RuntimeNodeItemType[]; + + return { + chatConfig: { variables: [] }, + params: { [NodeInputKeyEnum.updateList]: updateList }, + variables, + runtimeNodes: allNodes, + runtimeEdges: [], + workflowStreamResponse: undefined, + externalProvider: { externalWorkflowVariables: [] }, + runningAppInfo: { + id: 'test-app', + teamId: 'test-team', + tmbId: 'test-tmb', + name: 'Test App', + isChildApp: true // 跳过 SSE 推送 + }, + // 其他必需字段 + checkIsStopping: () => false, + mode: 'test' as const, + timezone: 'Asia/Shanghai', + runningUserInfo: { + username: 'test', + teamName: 'test', + memberName: 'test', + contact: '', + teamId: 'test-team', + tmbId: 'test-tmb' + }, + uid: 'test-uid', + chatId: 'test-chat', + histories: [], + query: [], + stream: false, + maxRunTimes: 100, + workflowDispatchDeep: 0, + node: allNodes[0], + mcpClientMemory: {} + } as any; +}; + +/** 构造全局变量更新项 */ +const createGlobalVarItem = ( + varKey: string, + value: any, + valueType: WorkflowIOValueTypeEnum, + renderType = FlowNodeInputTypeEnum.input +): TUpdateListItem => ({ + variable: [VARIABLE_NODE_ID, varKey], + value: ['', value], + valueType, + renderType +}); + +/** 构造节点输出变量更新项 */ +const createNodeOutputItem = ( + nodeId: string, + outputId: string, + value: any, + valueType: WorkflowIOValueTypeEnum +): TUpdateListItem => ({ + variable: [nodeId, outputId], + value: ['', value], + valueType, + renderType: FlowNodeInputTypeEnum.input +}); + +describe('dispatchUpdateVariable - 运算操作符', () => { + // ============ 数字运算 ============ + describe('数字运算', () => { + it('set: 直接赋值为 42', async () => { + const variables = { score: 0 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.set, value: 42 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(42); + }); + + it('set: 空输入返回 null,不更新变量', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.set, value: '' }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBeNull(); + }); + + it('add: 原值 10 加 5 等于 15', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.add, value: 5 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(15); + }); + + it('sub: 原值 10 减 3 等于 7', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.sub, value: 3 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(7); + }); + + it('mul: 原值 10 乘 2 等于 20', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.mul, value: 2 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(20); + }); + + it('div: 原值 10 除 4 等于 2.5', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.div, value: 4 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(2.5); + }); + + it('div: 除以 0 保留原值', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.div, value: 0 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(10); + }); + + it('add: 操作数为 NaN(undefined)时保留原值', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.add, value: undefined }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(10); + }); + + it('同一变量多次更新:+5 再 ×2,原值 10 → 15 → 30', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.add, value: 5 }, + WorkflowIOValueTypeEnum.number + ), + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.mul, value: 2 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(30); + }); + + it('变量未初始化(undefined)时作为 0 参与运算', async () => { + const variables: Record = {}; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.add, value: 5 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(5); + }); + }); + + // ============ 布尔运算 ============ + describe('布尔运算', () => { + it('set: True', async () => { + const variables = { flag: false }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.set, value: true }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + + it('set: False', async () => { + const variables = { flag: true }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.set, value: false }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(false); + }); + + it('negate: 原值 true → false', async () => { + const variables = { flag: true }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(false); + }); + + it('negate: 原值 false → true', async () => { + const variables = { flag: false }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + + it('negate: 原值 undefined → true(!falsy = true)', async () => { + const variables: Record = {}; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + + it('连续取反两次恢复原值', async () => { + const variables = { flag: true }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ), + createGlobalVarItem( + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + }); + + // ============ 旧数据兼容性 ============ + describe('旧数据兼容性', () => { + it('旧格式数字 ["", 10] → 直接赋值 10', async () => { + const variables = { score: 0 }; + const props = createMockProps({ + updateList: [createGlobalVarItem('score', 10, WorkflowIOValueTypeEnum.number)], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(10); + }); + + it('旧格式布尔 ["", true] → 赋值 true', async () => { + const variables = { flag: false }; + const props = createMockProps({ + updateList: [createGlobalVarItem('flag', true, WorkflowIOValueTypeEnum.boolean)], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + + it('旧格式布尔 ["", false] → 赋值 false', async () => { + const variables = { flag: true }; + const props = createMockProps({ + updateList: [createGlobalVarItem('flag', false, WorkflowIOValueTypeEnum.boolean)], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(false); + }); + + it('旧格式字符串 ["", "hello"] → 赋值 "hello"', async () => { + const variables = { text: '' }; + const props = createMockProps({ + updateList: [createGlobalVarItem('text', 'hello', WorkflowIOValueTypeEnum.string)], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.text).toBe('hello'); + }); + }); + + // ============ 节点输出变量 ============ + describe('节点输出变量', () => { + it('数字加法:更新节点输出', async () => { + const runtimeNodes: Partial[] = [ + { + nodeId: 'node1', + name: 'Node 1', + inputs: [], + outputs: [ + { + id: 'output1', + value: 10, + key: 'output1', + valueType: WorkflowIOValueTypeEnum.number, + label: 'output1', + type: 'static' as any + } + ] + } + ]; + const props = createMockProps({ + updateList: [ + createNodeOutputItem( + 'node1', + 'output1', + { operator: VariableUpdateOperatorEnum.add, value: 5 }, + WorkflowIOValueTypeEnum.number + ) + ], + runtimeNodes + }); + + await dispatchUpdateVariable(props); + const node = (props.runtimeNodes as RuntimeNodeItemType[]).find((n) => n.nodeId === 'node1'); + expect(node?.outputs?.find((o) => o.id === 'output1')?.value).toBe(15); + }); + + it('布尔取反:更新节点输出', async () => { + const runtimeNodes: Partial[] = [ + { + nodeId: 'node1', + name: 'Node 1', + inputs: [], + outputs: [ + { + id: 'flag', + value: true, + key: 'flag', + valueType: WorkflowIOValueTypeEnum.boolean, + label: 'flag', + type: 'static' as any + } + ] + } + ]; + const props = createMockProps({ + updateList: [ + createNodeOutputItem( + 'node1', + 'flag', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + runtimeNodes + }); + + await dispatchUpdateVariable(props); + const node = (props.runtimeNodes as RuntimeNodeItemType[]).find((n) => n.nodeId === 'node1'); + expect(node?.outputs?.find((o) => o.id === 'flag')?.value).toBe(false); + }); + }); + + // ============ 边界情况 ============ + describe('边界情况', () => { + it('未知 operator 兜底:返回 operand 值', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: 'unknown_op' as any, value: 99 }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBe(99); + }); + + it('negate + number → 对当前值取反', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + // negate 对 number 类型直接取反:!10 → false + expect(variables.score).toBe(false); + }); + + it('set + undefined(数字类型)→ 返回 null', async () => { + const variables = { score: 10 }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'score', + { operator: VariableUpdateOperatorEnum.set, value: undefined }, + WorkflowIOValueTypeEnum.number + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.score).toBeNull(); + }); + }); +}); From 5bd2736e7af549d80e3a3c453fefec891455452a Mon Sep 17 00:00:00 2001 From: heheer Date: Wed, 11 Feb 2026 16:49:57 +0800 Subject: [PATCH 2/4] array --- .../system/variableUpdate/constants.ts | 10 +- .../template/system/variableUpdate/index.tsx | 3 +- .../template/system/variableUpdate/type.d.ts | 16 - .../template/system/variableUpdate/type.ts | 24 ++ .../template/system/variableUpdate/utils.ts | 28 ++ packages/global/core/workflow/utils.ts | 16 +- .../workflow/dispatch/tools/runUpdateVar.ts | 108 +++--- packages/web/i18n/en/workflow.json | 2 + packages/web/i18n/zh-CN/workflow.json | 2 + packages/web/i18n/zh-Hant/workflow.json | 2 + .../Flow/nodes/NodeVariableUpdate.tsx | 360 +++++++++--------- .../workflow/dispatch/runUpdateVar.test.ts | 257 ++++++++++--- 12 files changed, 529 insertions(+), 299 deletions(-) delete mode 100644 packages/global/core/workflow/template/system/variableUpdate/type.d.ts create mode 100644 packages/global/core/workflow/template/system/variableUpdate/type.ts create mode 100644 packages/global/core/workflow/template/system/variableUpdate/utils.ts diff --git a/packages/global/core/workflow/template/system/variableUpdate/constants.ts b/packages/global/core/workflow/template/system/variableUpdate/constants.ts index a575841c31c3..81d4a319e709 100644 --- a/packages/global/core/workflow/template/system/variableUpdate/constants.ts +++ b/packages/global/core/workflow/template/system/variableUpdate/constants.ts @@ -4,11 +4,7 @@ export enum VariableUpdateOperatorEnum { sub = 'sub', mul = 'mul', div = 'div', - negate = 'negate' + negate = 'negate', + push = 'push', + clear = 'clear' } - -export const BooleanSelectValueEnum = { - setTrue: `${VariableUpdateOperatorEnum.set}:true`, - setFalse: `${VariableUpdateOperatorEnum.set}:false`, - negate: VariableUpdateOperatorEnum.negate -}; diff --git a/packages/global/core/workflow/template/system/variableUpdate/index.tsx b/packages/global/core/workflow/template/system/variableUpdate/index.tsx index a006b9594136..4d49954301a5 100644 --- a/packages/global/core/workflow/template/system/variableUpdate/index.tsx +++ b/packages/global/core/workflow/template/system/variableUpdate/index.tsx @@ -30,7 +30,8 @@ export const VariableUpdateNode: FlowNodeTemplateType = { value: [ { variable: ['', ''], - value: ['', ''], + updateType: 'set', + inputValue: '', valueType: WorkflowIOValueTypeEnum.string, renderType: FlowNodeInputTypeEnum.input } diff --git a/packages/global/core/workflow/template/system/variableUpdate/type.d.ts b/packages/global/core/workflow/template/system/variableUpdate/type.d.ts deleted file mode 100644 index 84445618a0f4..000000000000 --- a/packages/global/core/workflow/template/system/variableUpdate/type.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FlowNodeInputTypeEnum } from '../../../node/constant'; -import type { ReferenceItemValueType } from '../../../type/io'; -import type { WorkflowIOValueTypeEnum } from '../../../constants'; -import type { VariableUpdateOperatorEnum } from './constants'; - -export type TOperationValue = { - operator: VariableUpdateOperatorEnum; - value?: number | boolean | string; -}; - -export type TUpdateListItem = { - variable?: ReferenceItemValueType; - value?: ReferenceItemValueType | [string, string | TOperationValue]; - valueType?: WorkflowIOValueTypeEnum; - renderType: FlowNodeInputTypeEnum.input | FlowNodeInputTypeEnum.reference; -}; diff --git a/packages/global/core/workflow/template/system/variableUpdate/type.ts b/packages/global/core/workflow/template/system/variableUpdate/type.ts new file mode 100644 index 000000000000..059daaf698ac --- /dev/null +++ b/packages/global/core/workflow/template/system/variableUpdate/type.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { FlowNodeInputTypeEnum } from '../../../node/constant'; +import { WorkflowIOValueTypeEnum } from '../../../constants'; +import { VariableUpdateOperatorEnum } from './constants'; + +const ReferenceItemValueSchema = z.tuple([z.string(), z.union([z.string(), z.undefined()])]); + +export const UpdateListItemSchema = z.object({ + variable: ReferenceItemValueSchema.optional(), + valueType: z.enum(WorkflowIOValueTypeEnum).optional(), + renderType: z.union([ + z.literal(FlowNodeInputTypeEnum.input), + z.literal(FlowNodeInputTypeEnum.reference) + ]), + + updateType: z.enum(VariableUpdateOperatorEnum).optional(), + referenceValue: ReferenceItemValueSchema.optional(), + inputValue: z.any().optional(), + + /** @deprecated 旧格式字段,运行时由 normalizeUpdateItem 转换为 updateType + inputValue/referenceValue */ + value: z.any().optional() +}); + +export type TUpdateListItem = z.infer; diff --git a/packages/global/core/workflow/template/system/variableUpdate/utils.ts b/packages/global/core/workflow/template/system/variableUpdate/utils.ts new file mode 100644 index 000000000000..6d8de866c8d3 --- /dev/null +++ b/packages/global/core/workflow/template/system/variableUpdate/utils.ts @@ -0,0 +1,28 @@ +import type { TUpdateListItem } from './type'; +import { VariableUpdateOperatorEnum } from './constants'; + +export const normalizeUpdateItem = (item: TUpdateListItem): TUpdateListItem => { + // 新格式:已有 updateType,直接返回 + if (item.updateType !== undefined) return item; + + // 旧格式:value 是数组 + const raw = item.value; + if (Array.isArray(raw)) { + const [first, second] = raw as [string, string | undefined]; + const isRef = !!first; + return { + variable: item.variable, + valueType: item.valueType, + renderType: item.renderType, + updateType: VariableUpdateOperatorEnum.set, + referenceValue: isRef ? [first, second] : undefined, + inputValue: isRef ? undefined : second + }; + } + + return { + ...item, + updateType: VariableUpdateOperatorEnum.set, + inputValue: '' + }; +}; diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 05661a2e3902..a7ad6deb88f2 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -337,14 +337,26 @@ export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] } }; export const formatEditorVariablePickerIcon = ( - variables: { key: string; label: string; type?: `${VariableInputEnum}`; required?: boolean }[] + variables: { + key: string; + label: string; + type?: `${VariableInputEnum}`; + required?: boolean; + valueType?: WorkflowIOValueTypeEnum; + }[] ): EditorVariablePickerType[] => { return variables.map((item) => { const config = item.type ? variableMap[item.type] : variableMap['input']; + return { ...item, icon: config?.icon, - valueType: config?.defaultValueType + valueType: + ([VariableInputEnum.custom, VariableInputEnum.internal].includes( + item.type as VariableInputEnum + ) && + item.valueType) || + config?.defaultValueType }; }); }; diff --git a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts index e3a5ea32e5ed..b5f9aef65e2f 100644 --- a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts +++ b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts @@ -16,16 +16,26 @@ import { runtimeSystemVar2StoreType } from '../utils'; import { isValidReferenceValue } from '@fastgpt/global/core/workflow/utils'; import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils'; import { VariableUpdateOperatorEnum } from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; -import type { TOperationValue } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import { normalizeUpdateItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/utils'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; + +const numOp = (fn: (cur: number, operand: number) => number) => (cur: any, operand: any) => { + const n = Number(operand); + return isNaN(n) ? cur : fn(Number(cur) || 0, n); +}; const operatorHandlerMap: Record any> = { [VariableUpdateOperatorEnum.set]: (_cur, operand) => operand, - [VariableUpdateOperatorEnum.add]: (cur, operand) => (Number(cur) || 0) + operand, - [VariableUpdateOperatorEnum.sub]: (cur, operand) => (Number(cur) || 0) - operand, - [VariableUpdateOperatorEnum.mul]: (cur, operand) => (Number(cur) || 0) * operand, - [VariableUpdateOperatorEnum.div]: (cur, operand) => - operand !== 0 ? (Number(cur) || 0) / operand : Number(cur) || 0, - [VariableUpdateOperatorEnum.negate]: (cur) => !cur + [VariableUpdateOperatorEnum.add]: numOp((cur, n) => cur + n), + [VariableUpdateOperatorEnum.sub]: numOp((cur, n) => cur - n), + [VariableUpdateOperatorEnum.mul]: numOp((cur, n) => cur * n), + [VariableUpdateOperatorEnum.div]: numOp((cur, n) => (n !== 0 ? cur / n : cur)), + [VariableUpdateOperatorEnum.negate]: (cur) => !cur, + [VariableUpdateOperatorEnum.push]: (cur, operand) => [ + ...(Array.isArray(cur) ? cur : []), + operand + ], + [VariableUpdateOperatorEnum.clear]: () => [] }; type Props = ModuleDispatchProps<{ @@ -45,9 +55,11 @@ export const dispatchUpdateVariable = async (props: Props): Promise => } = props; const { updateList } = params; + console.log(updateList); const nodeIds = runtimeNodes.map((node) => node.nodeId); - const result = updateList.map((item) => { + const result = updateList.map((rawItem) => { + const item = normalizeUpdateItem(rawItem); const variable = item.variable; if (!isValidReferenceValue(variable, nodeIds)) { @@ -62,50 +74,46 @@ export const dispatchUpdateVariable = async (props: Props): Promise => } const value = (() => { - if (item.value?.[0]) { - return getReferenceVariableValue({ - value: item.value as ReferenceValueType, - variables, - nodes: runtimeNodes - }); - } - - const rawVal = item.value?.[1]; - - if (typeof rawVal === 'object' && rawVal !== null && 'operator' in rawVal) { - const { operator, value: operand } = rawVal as TOperationValue; - const handler = operatorHandlerMap[operator]; - if (!handler) return operand ?? null; - - const currentValue = getReferenceVariableValue({ - value: variable, - variables, - nodes: runtimeNodes - }); - - if (operator === VariableUpdateOperatorEnum.set) { - if (item.valueType === WorkflowIOValueTypeEnum.number) { - if (operand === '' || operand === undefined || operand === null) return null; - return Number(operand); - } - return valueTypeFormat(operand, item.valueType); - } - - const numOperand = Number(operand); - if (operator !== VariableUpdateOperatorEnum.negate && isNaN(numOperand)) { - return currentValue; - } - - const typedCurrentValue = valueTypeFormat(currentValue, item.valueType) ?? currentValue; - return handler(typedCurrentValue, numOperand); + const operator = item.updateType ?? VariableUpdateOperatorEnum.set; + const operand = + item.renderType === FlowNodeInputTypeEnum.reference + ? getReferenceVariableValue({ + value: item.referenceValue as ReferenceValueType, + variables, + nodes: runtimeNodes + }) + : replaceEditorVariable({ text: item.inputValue, nodes: runtimeNodes, variables }); + + const handler = operatorHandlerMap[operator]; + if (!handler) return operand ?? null; + + if (operator === VariableUpdateOperatorEnum.set) { + return valueTypeFormat(operand, item.valueType); } - const val = - typeof rawVal === 'string' - ? replaceEditorVariable({ text: rawVal, nodes: runtimeNodes, variables }) - : rawVal; - - return valueTypeFormat(val, item.valueType); + const currentValue = getReferenceVariableValue({ + value: variable, + variables, + nodes: runtimeNodes + }); + console.log(currentValue, item.valueType); + const typedCurrentValue = valueTypeFormat(currentValue, item.valueType) ?? currentValue; + + const processedOperand = + operator === VariableUpdateOperatorEnum.push && + typeof operand === 'string' && + (item.valueType === WorkflowIOValueTypeEnum.arrayObject || + item.valueType === WorkflowIOValueTypeEnum.arrayAny) + ? (() => { + try { + return JSON.parse(operand); + } catch { + return operand; + } + })() + : operand; + + return handler(typedCurrentValue, processedOperand); })(); if (varNodeId === VARIABLE_NODE_ID) { diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 888c62841532..932204aaf65a 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -235,6 +235,8 @@ "variable_update_operator_div": "Divide", "variable_update_number_placeholder": "Enter value", "variable_update_boolean_negate": "Negate", + "variable_update_operator_push": "Push", + "variable_update_operator_clear": "Clear", "workflow.My edit": "My Edit", "workflow.Switch_success": "Switch Successful", "workflow.Team cloud": "Team Cloud", diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index 117cc78e81b1..fa2bb5c6b501 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -235,6 +235,8 @@ "variable_update_operator_div": "除", "variable_update_number_placeholder": "输入值", "variable_update_boolean_negate": "取反", + "variable_update_operator_push": "追加", + "variable_update_operator_clear": "清空", "workflow.My edit": "我的编辑", "workflow.Switch_success": "切换成功", "workflow.Team cloud": "团队云端", diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index a80e37a0d9da..5129533866c8 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -235,6 +235,8 @@ "variable_update_operator_div": "除", "variable_update_number_placeholder": "輸入值", "variable_update_boolean_negate": "取反", + "variable_update_operator_push": "追加", + "variable_update_operator_clear": "清空", "workflow.My edit": "我的編輯", "workflow.Switch_success": "切換成功", "workflow.Team cloud": "團隊雲端", diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx index 06093007e79a..d8d267cf1de1 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate.tsx @@ -36,27 +36,13 @@ import { InputTypeEnum } from '@/components/core/app/formRender/constant'; import { WorkflowActionsContext } from '../../context/workflowActionsContext'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoizedFn } from 'ahooks'; -import { - BooleanSelectValueEnum, - VariableUpdateOperatorEnum -} from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; -import type { TOperationValue } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import { VariableUpdateOperatorEnum } from '@fastgpt/global/core/workflow/template/system/variableUpdate/constants'; +import { normalizeUpdateItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/utils'; import MySelect from '@fastgpt/web/components/common/MySelect'; import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput'; +import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; import ValueTypeLabel from './render/ValueTypeLabel'; -const getInputInitialValue = ( - valueType?: WorkflowIOValueTypeEnum -): TUpdateListItem['value'] | undefined => { - if (valueType === WorkflowIOValueTypeEnum.number) { - return ['', { operator: VariableUpdateOperatorEnum.set, value: '' }]; - } - if (valueType === WorkflowIOValueTypeEnum.boolean) { - return ['', { operator: VariableUpdateOperatorEnum.set, value: true }]; - } - return undefined; -}; - const NodeVariableUpdate = ({ data, selected }: NodeProps) => { const { inputs = [], nodeId } = data; const { t } = useTranslation(); @@ -88,7 +74,6 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => ); }, [feConfigs?.externalProviderWorkflowVariables]); - // Node inputs const updateList = useMemo( () => (inputs.find((input) => input.key === NodeInputKeyEnum.updateList) @@ -98,35 +83,38 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const numberOperatorList = useMemo( () => [ + { label: t('workflow:variable_update_operator_set'), value: VariableUpdateOperatorEnum.set }, + { label: t('workflow:variable_update_operator_add'), value: VariableUpdateOperatorEnum.add }, + { label: t('workflow:variable_update_operator_sub'), value: VariableUpdateOperatorEnum.sub }, + { label: t('workflow:variable_update_operator_mul'), value: VariableUpdateOperatorEnum.mul }, + { label: t('workflow:variable_update_operator_div'), value: VariableUpdateOperatorEnum.div } + ], + [t] + ); + + const booleanOperatorList = useMemo( + () => [ + { label: 'True', value: `${VariableUpdateOperatorEnum.set}:true` }, + { label: 'False', value: `${VariableUpdateOperatorEnum.set}:false` }, { - label: t('workflow:variable_update_operator_set'), - value: VariableUpdateOperatorEnum.set - }, - { - label: t('workflow:variable_update_operator_add'), - value: VariableUpdateOperatorEnum.add - }, - { - label: t('workflow:variable_update_operator_sub'), - value: VariableUpdateOperatorEnum.sub - }, - { - label: t('workflow:variable_update_operator_mul'), - value: VariableUpdateOperatorEnum.mul - }, - { - label: t('workflow:variable_update_operator_div'), - value: VariableUpdateOperatorEnum.div + label: t('workflow:variable_update_boolean_negate'), + value: VariableUpdateOperatorEnum.negate } ], [t] ); - const booleanOperatorList = useMemo( + const arrayOperatorList = useMemo( () => [ - { label: 'True', value: BooleanSelectValueEnum.setTrue }, - { label: 'False', value: BooleanSelectValueEnum.setFalse }, - { label: t('workflow:variable_update_boolean_negate'), value: BooleanSelectValueEnum.negate } + { label: t('workflow:variable_update_operator_set'), value: VariableUpdateOperatorEnum.set }, + { + label: t('workflow:variable_update_operator_push'), + value: VariableUpdateOperatorEnum.push + }, + { + label: t('workflow:variable_update_operator_clear'), + value: VariableUpdateOperatorEnum.clear + } ], [t] ); @@ -154,16 +142,13 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const { inputType, formParams = {} } = (() => { const value = updateItem.variable; if (!value) { - return { - inputType: InputTypeEnum.input - }; + return { inputType: InputTypeEnum.input }; } // Global variables: 根据变量的 inputType 决定 if (value[0] === VARIABLE_NODE_ID) { const variableList = appDetail.chatConfig.variables || []; const variable = variableList.find((item) => item.key === value[1]); if (variable) { - // 文件类型在变量更新节点中使用文本框,因为不在运行时上下文中,无法使用文件选择器 const inputType = variable.type === VariableInputEnum.file ? InputTypeEnum.textarea @@ -172,7 +157,6 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => return { inputType, formParams: { - // 获取变量中一些表单配置 maxLength: variable.maxLength, minLength: variable.minLength, min: variable.min, @@ -196,40 +180,150 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => else if (value[0] && value[1]) { const output = getNodeById(value[0])?.outputs.find((output) => output.id === value[1]); if (output) { - return { - inputType: valueTypeToInputType(output.valueType) - }; + return { inputType: valueTypeToInputType(output.valueType) }; } } - return { - inputType: InputTypeEnum.input - }; + return { inputType: InputTypeEnum.input }; })(); + const { valueType } = getRefData({ variable: updateItem.variable, getNodeById, systemConfigNode, chatConfig: appDetail.chatConfig }); - const onUpdateNewValue = (value: any) => { + + const item = normalizeUpdateItem(updateItem); + const operator = item.updateType ?? VariableUpdateOperatorEnum.set; + + const onUpdateFields = (fields: Partial) => { onUpdateList( - updateList.map((item, i) => - i === index - ? { - ...item, - value: - updateItem.renderType === FlowNodeInputTypeEnum.reference ? value : ['', value] - } - : item - ) + updateList.map((listItem, i) => (i === index ? { ...listItem, ...fields } : listItem)) ); }; + const inputRenderProps = { + inputType, + ...formParams, + isRichText: false, + variables: [...variables, ...externalProviderWorkflowVariables], + variableLabels: variables, + value: item.inputValue, + onChange: (val: any) => onUpdateFields({ inputValue: val }), + minH: 80 + }; + + const renderValueInput = () => { + if (updateItem.renderType === FlowNodeInputTypeEnum.reference) { + return ( + + onUpdateFields({ referenceValue: refValue as ReferenceItemValueType }) + } + /> + ); + } + + if (valueType === WorkflowIOValueTypeEnum.number) { + return ( + + + onUpdateFields({ updateType: op as VariableUpdateOperatorEnum }) + } + /> + onUpdateFields({ inputValue: val })} + /> + + ); + } + + if (valueType === WorkflowIOValueTypeEnum.boolean) { + return ( + + onUpdateFields( + val === VariableUpdateOperatorEnum.negate + ? { updateType: VariableUpdateOperatorEnum.negate, inputValue: undefined } + : { + updateType: VariableUpdateOperatorEnum.set, + inputValue: val.endsWith(':true') + } + ) + } + /> + ); + } + + if (valueType?.startsWith('array')) { + return ( + <> + { + const inputValue = + op === VariableUpdateOperatorEnum.clear + ? undefined + : op === VariableUpdateOperatorEnum.push && + valueType === WorkflowIOValueTypeEnum.arrayBoolean + ? true + : ''; + onUpdateFields({ updateType: op as VariableUpdateOperatorEnum, inputValue }); + }} + /> + {operator === VariableUpdateOperatorEnum.set && ( + + + + )} + {operator === VariableUpdateOperatorEnum.push && ( + + onUpdateFields({ inputValue: val })} + minH={80} + /> + + )} + + ); + } + + return ; + }; + return ( - + {t('common:core.workflow.variable')} @@ -242,8 +336,8 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => cursor={'pointer'} _hover={{ color: 'red.500' }} position={'absolute'} - top={3} - right={3} + top={4} + right={4} onClick={() => { onUpdateList(updateList.filter((_, i) => i !== index)); }} @@ -251,7 +345,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => )} - + ) => }).valueType; onUpdateList( - updateList.map((item, i) => + updateList.map((listItem, i) => i === index ? { - ...item, - value: getInputInitialValue(newValueType) ?? ['', ''], + ...listItem, + updateType: VariableUpdateOperatorEnum.set, + inputValue: newValueType === WorkflowIOValueTypeEnum.boolean ? true : '', valueType: newValueType, variable: value as ReferenceItemValueType } - : item + : listItem ) ); }} @@ -280,8 +375,8 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => /> - - + + {t('common:value')} @@ -290,115 +385,25 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => renderTypeIndex={updateItem.renderType === FlowNodeInputTypeEnum.reference ? 1 : 0} onChange={(e) => { const isReference = e === FlowNodeInputTypeEnum.reference; - onUpdateList( - updateList.map((item, i) => - i === index - ? { - ...item, - value: isReference ? undefined : getInputInitialValue(valueType), - renderType: isReference - ? FlowNodeInputTypeEnum.reference - : FlowNodeInputTypeEnum.input - } - : item - ) - ); + onUpdateFields({ + renderType: isReference + ? FlowNodeInputTypeEnum.reference + : FlowNodeInputTypeEnum.input, + updateType: VariableUpdateOperatorEnum.set, + referenceValue: undefined, + inputValue: isReference + ? undefined + : valueType === WorkflowIOValueTypeEnum.boolean + ? true + : '' + }); }} /> - - {(() => { - if (updateItem.renderType === FlowNodeInputTypeEnum.reference) { - return ( - - ); - } - - if (valueType === WorkflowIOValueTypeEnum.number) { - const raw = updateItem.value?.[1]; - const opValue: TOperationValue = - typeof raw === 'object' && raw !== null && 'operator' in raw - ? (raw as TOperationValue) - : { operator: VariableUpdateOperatorEnum.set, value: raw ?? '' }; - - return ( - - onUpdateNewValue({ ...opValue, operator: op })} - /> - - onUpdateNewValue({ ...opValue, value: val }) - } - /> - - ); - } - - if (valueType === WorkflowIOValueTypeEnum.boolean) { - const raw = updateItem.value?.[1]; - const opValue: TOperationValue = - typeof raw === 'object' && raw !== null && 'operator' in raw - ? (raw as TOperationValue) - : { operator: VariableUpdateOperatorEnum.set, value: !!raw }; - - const selectValue = - opValue.operator === VariableUpdateOperatorEnum.negate - ? BooleanSelectValueEnum.negate - : opValue.value === false - ? BooleanSelectValueEnum.setFalse - : BooleanSelectValueEnum.setTrue; - - return ( - { - if (val === BooleanSelectValueEnum.negate) { - onUpdateNewValue({ - operator: VariableUpdateOperatorEnum.negate - }); - } else { - onUpdateNewValue({ - operator: VariableUpdateOperatorEnum.set, - value: val === BooleanSelectValueEnum.setTrue - }); - } - }} - /> - ); - } - - return ( - - ); - })()} + + {renderValueInput()} ); @@ -407,7 +412,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const Render = useMemo(() => { return ( - + {updateList.map((updateItem, index) => ( @@ -432,7 +437,8 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => ...updateList, { variable: ['', ''], - value: ['', ''], + updateType: VariableUpdateOperatorEnum.set, + inputValue: '', renderType: FlowNodeInputTypeEnum.input } ]); diff --git a/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts b/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts index fb0977e7733c..c1f1d9c2fdfd 100644 --- a/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts +++ b/test/cases/service/core/app/workflow/dispatch/runUpdateVar.test.ts @@ -43,9 +43,8 @@ const createMockProps = ({ teamId: 'test-team', tmbId: 'test-tmb', name: 'Test App', - isChildApp: true // 跳过 SSE 推送 + isChildApp: true }, - // 其他必需字段 checkIsStopping: () => false, mode: 'test' as const, timezone: 'Asia/Shanghai', @@ -69,30 +68,30 @@ const createMockProps = ({ } as any; }; -/** 构造全局变量更新项 */ +/** 构造全局变量更新项(新扁平格式) */ const createGlobalVarItem = ( varKey: string, - value: any, + fields: Pick, valueType: WorkflowIOValueTypeEnum, renderType = FlowNodeInputTypeEnum.input ): TUpdateListItem => ({ variable: [VARIABLE_NODE_ID, varKey], - value: ['', value], valueType, - renderType + renderType, + ...fields }); /** 构造节点输出变量更新项 */ const createNodeOutputItem = ( nodeId: string, outputId: string, - value: any, + fields: Pick, valueType: WorkflowIOValueTypeEnum ): TUpdateListItem => ({ variable: [nodeId, outputId], - value: ['', value], valueType, - renderType: FlowNodeInputTypeEnum.input + renderType: FlowNodeInputTypeEnum.input, + ...fields }); describe('dispatchUpdateVariable - 运算操作符', () => { @@ -104,7 +103,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.set, value: 42 }, + { updateType: VariableUpdateOperatorEnum.set, inputValue: 42 }, WorkflowIOValueTypeEnum.number ) ], @@ -115,13 +114,13 @@ describe('dispatchUpdateVariable - 运算操作符', () => { expect(variables.score).toBe(42); }); - it('set: 空输入返回 null,不更新变量', async () => { + it('set: 空输入返回 null', async () => { const variables = { score: 10 }; const props = createMockProps({ updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.set, value: '' }, + { updateType: VariableUpdateOperatorEnum.set, inputValue: '' }, WorkflowIOValueTypeEnum.number ) ], @@ -138,7 +137,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.add, value: 5 }, + { updateType: VariableUpdateOperatorEnum.add, inputValue: 5 }, WorkflowIOValueTypeEnum.number ) ], @@ -155,7 +154,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.sub, value: 3 }, + { updateType: VariableUpdateOperatorEnum.sub, inputValue: 3 }, WorkflowIOValueTypeEnum.number ) ], @@ -172,7 +171,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.mul, value: 2 }, + { updateType: VariableUpdateOperatorEnum.mul, inputValue: 2 }, WorkflowIOValueTypeEnum.number ) ], @@ -189,7 +188,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.div, value: 4 }, + { updateType: VariableUpdateOperatorEnum.div, inputValue: 4 }, WorkflowIOValueTypeEnum.number ) ], @@ -206,7 +205,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.div, value: 0 }, + { updateType: VariableUpdateOperatorEnum.div, inputValue: 0 }, WorkflowIOValueTypeEnum.number ) ], @@ -223,7 +222,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.add, value: undefined }, + { updateType: VariableUpdateOperatorEnum.add, inputValue: undefined }, WorkflowIOValueTypeEnum.number ) ], @@ -240,12 +239,12 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.add, value: 5 }, + { updateType: VariableUpdateOperatorEnum.add, inputValue: 5 }, WorkflowIOValueTypeEnum.number ), createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.mul, value: 2 }, + { updateType: VariableUpdateOperatorEnum.mul, inputValue: 2 }, WorkflowIOValueTypeEnum.number ) ], @@ -262,7 +261,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.add, value: 5 }, + { updateType: VariableUpdateOperatorEnum.add, inputValue: 5 }, WorkflowIOValueTypeEnum.number ) ], @@ -282,7 +281,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.set, value: true }, + { updateType: VariableUpdateOperatorEnum.set, inputValue: true }, WorkflowIOValueTypeEnum.boolean ) ], @@ -299,7 +298,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.set, value: false }, + { updateType: VariableUpdateOperatorEnum.set, inputValue: false }, WorkflowIOValueTypeEnum.boolean ) ], @@ -316,7 +315,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ) ], @@ -333,7 +332,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ) ], @@ -344,13 +343,47 @@ describe('dispatchUpdateVariable - 运算操作符', () => { expect(variables.flag).toBe(true); }); + it('negate: 原值为字符串 "false" → true(字符串自动转换)', async () => { + const variables = { flag: 'false' }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { updateType: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(true); + }); + + it('negate: 原值为字符串 "true" → false(字符串自动转换)', async () => { + const variables = { flag: 'true' }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'flag', + { updateType: VariableUpdateOperatorEnum.negate }, + WorkflowIOValueTypeEnum.boolean + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.flag).toBe(false); + }); + it('negate: 原值 undefined → true(!falsy = true)', async () => { const variables: Record = {}; const props = createMockProps({ updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ) ], @@ -367,12 +400,12 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ), createGlobalVarItem( 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ) ], @@ -384,12 +417,107 @@ describe('dispatchUpdateVariable - 运算操作符', () => { }); }); - // ============ 旧数据兼容性 ============ + // ============ 数组运算 ============ + describe('数组运算', () => { + it('set: 直接赋值数组', async () => { + const variables = { list: ['a', 'b'] }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.set, inputValue: ['x', 'y'] }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual(['x', 'y']); + }); + + it('push: 追加元素到末尾', async () => { + const variables = { list: ['a', 'b'] }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.push, inputValue: 'x' }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual(['a', 'b', 'x']); + }); + + it('clear: 清空数组', async () => { + const variables = { list: ['a', 'b'] }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.clear }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual([]); + }); + + it('push: 原值 undefined → [newItem]', async () => { + const variables: Record = {}; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.push, inputValue: 'x' }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual(['x']); + }); + + it('clear: 原值 undefined → []', async () => { + const variables: Record = {}; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.clear }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual([]); + }); + }); + + // ============ 旧数据兼容性(生产环境格式) ============ describe('旧数据兼容性', () => { it('旧格式数字 ["", 10] → 直接赋值 10', async () => { const variables = { score: 0 }; const props = createMockProps({ - updateList: [createGlobalVarItem('score', 10, WorkflowIOValueTypeEnum.number)], + updateList: [ + { + variable: [VARIABLE_NODE_ID, 'score'], + value: ['', 10], + valueType: WorkflowIOValueTypeEnum.number, + renderType: FlowNodeInputTypeEnum.input + } as any + ], variables }); @@ -400,7 +528,14 @@ describe('dispatchUpdateVariable - 运算操作符', () => { it('旧格式布尔 ["", true] → 赋值 true', async () => { const variables = { flag: false }; const props = createMockProps({ - updateList: [createGlobalVarItem('flag', true, WorkflowIOValueTypeEnum.boolean)], + updateList: [ + { + variable: [VARIABLE_NODE_ID, 'flag'], + value: ['', true], + valueType: WorkflowIOValueTypeEnum.boolean, + renderType: FlowNodeInputTypeEnum.input + } as any + ], variables }); @@ -411,7 +546,14 @@ describe('dispatchUpdateVariable - 运算操作符', () => { it('旧格式布尔 ["", false] → 赋值 false', async () => { const variables = { flag: true }; const props = createMockProps({ - updateList: [createGlobalVarItem('flag', false, WorkflowIOValueTypeEnum.boolean)], + updateList: [ + { + variable: [VARIABLE_NODE_ID, 'flag'], + value: ['', false], + valueType: WorkflowIOValueTypeEnum.boolean, + renderType: FlowNodeInputTypeEnum.input + } as any + ], variables }); @@ -422,7 +564,14 @@ describe('dispatchUpdateVariable - 运算操作符', () => { it('旧格式字符串 ["", "hello"] → 赋值 "hello"', async () => { const variables = { text: '' }; const props = createMockProps({ - updateList: [createGlobalVarItem('text', 'hello', WorkflowIOValueTypeEnum.string)], + updateList: [ + { + variable: [VARIABLE_NODE_ID, 'text'], + value: ['', 'hello'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input + } as any + ], variables }); @@ -456,7 +605,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { createNodeOutputItem( 'node1', 'output1', - { operator: VariableUpdateOperatorEnum.add, value: 5 }, + { updateType: VariableUpdateOperatorEnum.add, inputValue: 5 }, WorkflowIOValueTypeEnum.number ) ], @@ -491,7 +640,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { createNodeOutputItem( 'node1', 'flag', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.negate }, WorkflowIOValueTypeEnum.boolean ) ], @@ -512,7 +661,7 @@ describe('dispatchUpdateVariable - 运算操作符', () => { updateList: [ createGlobalVarItem( 'score', - { operator: 'unknown_op' as any, value: 99 }, + { updateType: 'unknown_op' as any, inputValue: 99 }, WorkflowIOValueTypeEnum.number ) ], @@ -523,13 +672,13 @@ describe('dispatchUpdateVariable - 运算操作符', () => { expect(variables.score).toBe(99); }); - it('negate + number → 对当前值取反', async () => { + it('set + undefined(数字类型)→ 返回 undefined', async () => { const variables = { score: 10 }; const props = createMockProps({ updateList: [ createGlobalVarItem( 'score', - { operator: VariableUpdateOperatorEnum.negate }, + { updateType: VariableUpdateOperatorEnum.set, inputValue: undefined }, WorkflowIOValueTypeEnum.number ) ], @@ -537,25 +686,41 @@ describe('dispatchUpdateVariable - 运算操作符', () => { }); await dispatchUpdateVariable(props); - // negate 对 number 类型直接取反:!10 → false - expect(variables.score).toBe(false); + expect(variables.score).toBeUndefined(); }); - it('set + undefined(数字类型)→ 返回 null', async () => { - const variables = { score: 10 }; + it('push arrayObject:JSON 字符串 operand 自动解析为对象', async () => { + const variables = { items: [{ id: 1 }] }; const props = createMockProps({ updateList: [ createGlobalVarItem( - 'score', - { operator: VariableUpdateOperatorEnum.set, value: undefined }, - WorkflowIOValueTypeEnum.number + 'items', + { updateType: VariableUpdateOperatorEnum.push, inputValue: '{"id":2}' }, + WorkflowIOValueTypeEnum.arrayObject ) ], variables }); await dispatchUpdateVariable(props); - expect(variables.score).toBeNull(); + expect(variables.items).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('push arrayString:字符串 operand 保持原样(不做 JSON 解析)', async () => { + const variables = { list: ['a'] }; + const props = createMockProps({ + updateList: [ + createGlobalVarItem( + 'list', + { updateType: VariableUpdateOperatorEnum.push, inputValue: '{"not":"parsed"}' }, + WorkflowIOValueTypeEnum.arrayString + ) + ], + variables + }); + + await dispatchUpdateVariable(props); + expect(variables.list).toEqual(['a', '{"not":"parsed"}']); }); }); }); From d4cc9dd210b6ba44a2652a3ba66e71a8dfed08d9 Mon Sep 17 00:00:00 2001 From: heheer Date: Wed, 11 Feb 2026 17:00:16 +0800 Subject: [PATCH 3/4] fix --- packages/global/core/workflow/utils.ts | 2 +- .../core/workflow/dispatch/tools/runUpdateVar.ts | 16 +++++----------- .../app/src/components/core/app/VariableEdit.tsx | 4 ++-- .../app/detail/SimpleApp/EditForm.tsx | 4 ++-- .../Flow/nodes/NodeWorkflowStart.tsx | 7 ++----- projects/app/src/web/core/workflow/utils.ts | 4 ++-- 6 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index a7ad6deb88f2..28a47000baf0 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -336,7 +336,7 @@ export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] } }; }; -export const formatEditorVariablePickerIcon = ( +export const formatEditorVariable = ( variables: { key: string; label: string; diff --git a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts index b5f9aef65e2f..a59ab46a6df9 100644 --- a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts +++ b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts @@ -19,17 +19,13 @@ import { VariableUpdateOperatorEnum } from '@fastgpt/global/core/workflow/templa import { normalizeUpdateItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/utils'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -const numOp = (fn: (cur: number, operand: number) => number) => (cur: any, operand: any) => { - const n = Number(operand); - return isNaN(n) ? cur : fn(Number(cur) || 0, n); -}; - const operatorHandlerMap: Record any> = { [VariableUpdateOperatorEnum.set]: (_cur, operand) => operand, - [VariableUpdateOperatorEnum.add]: numOp((cur, n) => cur + n), - [VariableUpdateOperatorEnum.sub]: numOp((cur, n) => cur - n), - [VariableUpdateOperatorEnum.mul]: numOp((cur, n) => cur * n), - [VariableUpdateOperatorEnum.div]: numOp((cur, n) => (n !== 0 ? cur / n : cur)), + [VariableUpdateOperatorEnum.add]: (cur, operand) => Number(cur) + Number(operand), + [VariableUpdateOperatorEnum.sub]: (cur, operand) => Number(cur) - Number(operand), + [VariableUpdateOperatorEnum.mul]: (cur, operand) => Number(cur) * Number(operand), + [VariableUpdateOperatorEnum.div]: (cur, operand) => + Number(operand) !== 0 ? Number(cur) / Number(operand) : cur, [VariableUpdateOperatorEnum.negate]: (cur) => !cur, [VariableUpdateOperatorEnum.push]: (cur, operand) => [ ...(Array.isArray(cur) ? cur : []), @@ -55,7 +51,6 @@ export const dispatchUpdateVariable = async (props: Props): Promise => } = props; const { updateList } = params; - console.log(updateList); const nodeIds = runtimeNodes.map((node) => node.nodeId); const result = updateList.map((rawItem) => { @@ -96,7 +91,6 @@ export const dispatchUpdateVariable = async (props: Props): Promise => variables, nodes: runtimeNodes }); - console.log(currentValue, item.valueType); const typedCurrentValue = valueTypeFormat(currentValue, item.valueType) ?? currentValue; const processedOperand = diff --git a/projects/app/src/components/core/app/VariableEdit.tsx b/projects/app/src/components/core/app/VariableEdit.tsx index bf0289f5817a..3cb76f1ac5e9 100644 --- a/projects/app/src/components/core/app/VariableEdit.tsx +++ b/projects/app/src/components/core/app/VariableEdit.tsx @@ -19,7 +19,7 @@ import { import type { VariableItemType } from '@fastgpt/global/core/app/type.d'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useTranslation } from 'next-i18next'; -import { formatEditorVariablePickerIcon } from '@fastgpt/global/core/workflow/utils'; +import { formatEditorVariable } from '@fastgpt/global/core/workflow/utils'; import ChatFunctionTip from './Tip'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import MyIconButton from '@fastgpt/web/components/common/Icon/button'; @@ -77,7 +77,7 @@ const VariableEdit = ({ const [editingVariable, setEditingVariable] = useState(null); const formatVariables = useMemo(() => { - const results = formatEditorVariablePickerIcon(variables); + const results = formatEditorVariable(variables); return results.map((item) => { const variable = variables.find((variable) => variable.key === item.key)!; return { diff --git a/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx b/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx index 927fdd558f43..64e3504eadc7 100644 --- a/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx +++ b/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx @@ -19,7 +19,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar'; import MyIcon from '@fastgpt/web/components/common/Icon'; import VariableEdit from '@/components/core/app/VariableEdit'; import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor'; -import { formatEditorVariablePickerIcon } from '@fastgpt/global/core/workflow/utils'; +import { formatEditorVariable } from '@fastgpt/global/core/workflow/utils'; import SearchParamsTip from '@/components/core/dataset/SearchParamsTip'; import SettingLLMModel from '@/components/core/ai/SettingLLMModel'; import { TTSTypeEnum } from '@/web/core/app/constants'; @@ -86,7 +86,7 @@ const EditForm = ({ const formatVariables = useMemo( () => - formatEditorVariablePickerIcon([ + formatEditorVariable([ ...workflowSystemVariables.filter( (variable) => !['appId', 'chatId', 'responseChatItemId', 'histories'].includes(variable.key) diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx index 18ac297a2212..3537b5860d46 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx @@ -13,10 +13,7 @@ import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/const import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { AppContext } from '@/pageComponents/app/detail/context'; import { workflowSystemVariables } from '@/web/core/app/utils'; -import { - formatEditorVariablePickerIcon, - getAppChatConfig -} from '@fastgpt/global/core/workflow/utils'; +import { formatEditorVariable, getAppChatConfig } from '@fastgpt/global/core/workflow/utils'; import MyDivider from '@fastgpt/web/components/common/MyDivider'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; @@ -27,7 +24,7 @@ const NodeStart = ({ data, selected }: NodeProps) => { const systemConfigNode = useContextSelector(WorkflowBufferDataContext, (v) => v.systemConfigNode); const customGlobalVariables = useMemoEnhance(() => { - const globalVariables = formatEditorVariablePickerIcon( + const globalVariables = formatEditorVariable( getAppChatConfig({ chatConfig: appDetail.chatConfig, systemConfigNode, diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 11fb72f32a40..f9458680e0a2 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -20,7 +20,7 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/ import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { type EditorVariablePickerType } from '@fastgpt/web/components/common/Textarea/PromptEditor/type'; import { - formatEditorVariablePickerIcon, + formatEditorVariable, getAppChatConfig, getHandleId } from '@fastgpt/global/core/workflow/utils'; @@ -655,7 +655,7 @@ export const getWorkflowGlobalVariables = ({ systemConfigNode?: StoreNodeItemType; chatConfig: AppChatConfigType; }): EditorVariablePickerType[] => { - const globalVariables = formatEditorVariablePickerIcon( + const globalVariables = formatEditorVariable( getAppChatConfig({ chatConfig, systemConfigNode, From e2ae2a765578a6f97b0ae9a9b8c4636578218853 Mon Sep 17 00:00:00 2001 From: heheer Date: Wed, 11 Feb 2026 17:26:45 +0800 Subject: [PATCH 4/4] negate operator handle string boolean for backward compatibility --- packages/service/core/workflow/dispatch/tools/runUpdateVar.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts index a59ab46a6df9..40e5f996cd6b 100644 --- a/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts +++ b/packages/service/core/workflow/dispatch/tools/runUpdateVar.ts @@ -26,7 +26,8 @@ const operatorHandlerMap: Record any> = { [VariableUpdateOperatorEnum.mul]: (cur, operand) => Number(cur) * Number(operand), [VariableUpdateOperatorEnum.div]: (cur, operand) => Number(operand) !== 0 ? Number(cur) / Number(operand) : cur, - [VariableUpdateOperatorEnum.negate]: (cur) => !cur, + [VariableUpdateOperatorEnum.negate]: (cur) => + !(typeof cur === 'string' ? cur.toLowerCase() === 'true' : cur), [VariableUpdateOperatorEnum.push]: (cur, operand) => [ ...(Array.isArray(cur) ? cur : []), operand