From b7cdb3fb860e2c14e238b5838d2f148e89baa11a Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:01:29 +0800 Subject: [PATCH] feat: support multiple valueTypes for text input variables (#6801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 文本输入框变量支持多 valueType * fix: 文本输入框变量非 string valueType 的渲染映射 - formRender/utils.ts: variableInputTypeToInputType 在 text input 场景下 按 valueType 派生,非 string 走 JSONEditor/textarea,让 Chat 表单与 调试面板能正确渲染 object/array/any 变量 - packages/global/core/workflow/utils.ts: appData2FlowNodeIO 的 renderTypeMap 对 text input + 非 string 使用 [JSONEditor, reference], 保证父工作流节点输入同步跟进 - 补 appData2FlowNodeIO 的 text input valueType 分支测试 * feat(workflow): 全局变量联动清理节点引用及变量更新节点必填校验 * fix(workflow): 文本输入变量 legacy 兼容与引用存活校验 * refactor(workflow): 抽出文本输入变量 valueType 兜底 helper * feat: adapt test for all node type * revert(workflow): 移除全局变量引用联动清理 * feat: remove any in text input * doc --------- Co-authored-by: archer <545436317@qq.com> --- .../docs/self-host/upgrading/4-15/4150.mdx | 2 + document/data/doc-last-modified.json | 2 +- packages/global/core/workflow/constants.ts | 9 + packages/global/core/workflow/utils.ts | 26 ++- .../core/app/VariableEditModal/index.tsx | 24 ++- .../core/app/VariableEditModal/utils.ts | 20 +++ .../components/core/app/formRender/utils.ts | 4 +- .../ChatBox/components/VariableInputForm.tsx | 2 +- .../components/home/ChatHomeVariablesForm.tsx | 2 +- .../components/VariablePopover.tsx | 6 +- .../Flow/hooks/useDebug.tsx | 6 +- .../nodes/NodePluginIO/InputTypeConfig.tsx | 55 +++--- projects/app/src/web/core/workflow/utils.ts | 52 +++++- .../core/app/VariableEditModal/utils.test.ts | 40 +++++ .../core/app/formRender/utils.test.ts | 151 ++++++++++++++++ .../test/web/core/app/workflow/utils.test.ts | 162 +++++++++++++++++- test/cases/global/core/workflow/utils.test.ts | 83 +++++++++ 17 files changed, 599 insertions(+), 47 deletions(-) create mode 100644 projects/app/src/components/core/app/VariableEditModal/utils.ts create mode 100644 projects/app/test/components/core/app/VariableEditModal/utils.test.ts create mode 100644 projects/app/test/components/core/app/formRender/utils.test.ts diff --git a/document/content/docs/self-host/upgrading/4-15/4150.mdx b/document/content/docs/self-host/upgrading/4-15/4150.mdx index 0d9b9e4d6d..7962e5f5ae 100644 --- a/document/content/docs/self-host/upgrading/4-15/4150.mdx +++ b/document/content/docs/self-host/upgrading/4-15/4150.mdx @@ -5,6 +5,8 @@ description: 'FastGPT V4.15.0 更新说明' ## 🚀 新增内容 +1. 新增循环节点,弃用旧的批量执行。 +2. 全局变量输入框支持输入 object 类型数据。 ## ⚙️ 优化 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index b1aa0d3c56..9541bd14dc 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -230,7 +230,7 @@ "document/content/docs/self-host/upgrading/4-14/41412.mdx": "2026-04-21T23:04:26+08:00", "document/content/docs/self-host/upgrading/4-14/41413.en.mdx": "2026-04-21T23:04:26+08:00", "document/content/docs/self-host/upgrading/4-14/41413.mdx": "2026-04-21T23:04:26+08:00", - "document/content/docs/self-host/upgrading/4-14/41414.mdx": "2026-04-24T18:21:37+08:00", + "document/content/docs/self-host/upgrading/4-14/41414.mdx": "2026-04-24T18:34:55+08:00", "document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00", diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index a42d811500..d5488a5996 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -373,6 +373,15 @@ type VariableConfigType = { description?: string; }; +export const textInputVariableValueTypes: WorkflowIOValueTypeEnum[] = [ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayNumber, + WorkflowIOValueTypeEnum.arrayBoolean, + WorkflowIOValueTypeEnum.arrayObject +]; + export const variableConfigs: VariableConfigType[][] = [ [ { diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 6e36853769..98826d241a 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -10,7 +10,8 @@ import { VariableInputEnum, variableMap, VARIABLE_NODE_ID, - NodeOutputKeyEnum + NodeOutputKeyEnum, + textInputVariableValueTypes } from './constants'; import { type FlowNodeInputItemType, @@ -234,6 +235,14 @@ export const pluginData2FlowNodeIO = ({ }; }; +const jsonRenderValueTypes = new Set([ + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayNumber, + WorkflowIOValueTypeEnum.arrayBoolean, + WorkflowIOValueTypeEnum.arrayObject +]); + export const appData2FlowNodeIO = ({ chatConfig }: { @@ -245,8 +254,19 @@ export const appData2FlowNodeIO = ({ const variableInput = !chatConfig?.variables ? [] : chatConfig.variables.map((item) => { + // Legacy input+非法 valueType(如 number/boolean)视同 string,避免画布控件与 valueType 错配 + const normalizedValueType = + item.type === VariableInputEnum.input && + item.valueType !== undefined && + !textInputVariableValueTypes.includes(item.valueType) + ? WorkflowIOValueTypeEnum.string + : item.valueType; + const isJsonValueType = + !!normalizedValueType && jsonRenderValueTypes.has(normalizedValueType); const renderTypeMap: Record = { - [VariableInputEnum.input]: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + [VariableInputEnum.input]: isJsonValueType + ? [FlowNodeInputTypeEnum.JSONEditor, FlowNodeInputTypeEnum.reference] + : [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], [VariableInputEnum.textarea]: [ FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference @@ -271,7 +291,7 @@ export const appData2FlowNodeIO = ({ label: item.label, debugLabel: item.label, description: '', - valueType: item.valueType || WorkflowIOValueTypeEnum.any, + valueType: normalizedValueType || WorkflowIOValueTypeEnum.any, required: item.required, defaultValue: item.defaultValue, value: item.defaultValue, diff --git a/projects/app/src/components/core/app/VariableEditModal/index.tsx b/projects/app/src/components/core/app/VariableEditModal/index.tsx index 11e82f274e..c100f242c8 100644 --- a/projects/app/src/components/core/app/VariableEditModal/index.tsx +++ b/projects/app/src/components/core/app/VariableEditModal/index.tsx @@ -14,6 +14,7 @@ import InputTypeSelector from '@fastgpt/web/components/common/InputTypeSelector' import { getVariableInputTypeList } from '@fastgpt/web/components/common/InputTypeSelector/configs'; import { addVariable } from '../VariableEdit'; import { useValidateFieldName, useSubmitErrorHandler } from '../utils/formValidation'; +import { snapTextInputValueType } from './utils'; const VariableEditModal = ({ onClose, @@ -38,7 +39,14 @@ const VariableEditModal = ({ const type = watch('type'); useEffect(() => { reset(variable); - }, [variable, reset]); + if (variable?.type === VariableInputEnum.input) { + const snap = snapTextInputValueType(variable.valueType); + if (snap.resetDefault) { + setValue('valueType', snap.valueType); + setValue('defaultValue', ''); + } + } + }, [variable, reset, setValue]); const inputTypeList = useMemo(() => getVariableInputTypeList(), []); @@ -67,6 +75,13 @@ const VariableEditModal = ({ if (typeEnum === VariableInputEnum.file) { setValue('canLocalUpload', true); } + if (typeEnum === VariableInputEnum.input) { + const snap = snapTextInputValueType(value.valueType); + if (snap.resetDefault) { + setValue('valueType', snap.valueType); + setValue('defaultValue', ''); + } + } setValue('type', typeEnum); }, @@ -88,16 +103,17 @@ const VariableEditModal = ({ return; } - // For custom and internal types, user can select valueType manually, so don't override it - // For other types, set valueType from defaultValueType + // custom/internal/input 允许用户自选 valueType,其余强制用模板 defaultValueType if ( - ![VariableInputEnum.custom, VariableInputEnum.internal, VariableInputEnum].includes( + ![VariableInputEnum.custom, VariableInputEnum.internal, VariableInputEnum.input].includes( data.type ) ) { data.valueType = inputTypeList .flat() .find((item) => item.value === data.type)?.defaultValueType; + } else if (data.type === VariableInputEnum.input) { + data.valueType = snapTextInputValueType(data.valueType).valueType; } // Special types set required = false diff --git a/projects/app/src/components/core/app/VariableEditModal/utils.ts b/projects/app/src/components/core/app/VariableEditModal/utils.ts new file mode 100644 index 0000000000..9f7aef9733 --- /dev/null +++ b/projects/app/src/components/core/app/VariableEditModal/utils.ts @@ -0,0 +1,20 @@ +import { + WorkflowIOValueTypeEnum, + textInputVariableValueTypes +} from '@fastgpt/global/core/workflow/constants'; + +// 对 text input 变量的 valueType 做 legacy 兜底: +// - undefined: 视为隐式 string,不清 defaultValue +// - 非法值: snap 回 string 并要求清空 defaultValue +// - 合法值: 原样保留 +export const snapTextInputValueType = ( + valueType: WorkflowIOValueTypeEnum | undefined +): { valueType: WorkflowIOValueTypeEnum; resetDefault: boolean } => { + if (valueType === undefined) { + return { valueType: WorkflowIOValueTypeEnum.string, resetDefault: false }; + } + if (!textInputVariableValueTypes.includes(valueType)) { + return { valueType: WorkflowIOValueTypeEnum.string, resetDefault: true }; + } + return { valueType, resetDefault: false }; +}; diff --git a/projects/app/src/components/core/app/formRender/utils.ts b/projects/app/src/components/core/app/formRender/utils.ts index f9342d1ac6..a6ab39e175 100644 --- a/projects/app/src/components/core/app/formRender/utils.ts +++ b/projects/app/src/components/core/app/formRender/utils.ts @@ -10,7 +10,9 @@ export const variableInputTypeToInputType = ( inputType: VariableInputEnum, valueType?: WorkflowIOValueTypeEnum ) => { - if (inputType === VariableInputEnum.input) return InputTypeEnum.input; + if (inputType === VariableInputEnum.input) { + return valueType ? valueTypeToInputType(valueType) : InputTypeEnum.input; + } if (inputType === VariableInputEnum.textarea) return InputTypeEnum.textarea; if (inputType === VariableInputEnum.numberInput) return InputTypeEnum.numberInput; if (inputType === VariableInputEnum.select) return InputTypeEnum.select; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/VariableInputForm.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/VariableInputForm.tsx index b9db2d3caf..fd11087fe4 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/VariableInputForm.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/VariableInputForm.tsx @@ -193,7 +193,7 @@ const VariableInputForm = ({ isUnChange={isUnChange} key={item.key} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} bg={'myGray.50'} form={variablesForm} fieldName={`variables.${item.key}`} diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/home/ChatHomeVariablesForm.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/home/ChatHomeVariablesForm.tsx index 5a740d84b6..26ec9acfea 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/home/ChatHomeVariablesForm.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/home/ChatHomeVariablesForm.tsx @@ -64,7 +64,7 @@ const ChatHomeVariablesForm = ({ chatForm }: Props) => { key={item.key} fieldName={`variables.${item.key}`} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} bg={'myGray.50'} /> diff --git a/projects/app/src/components/core/chat/ChatContainer/components/VariablePopover.tsx b/projects/app/src/components/core/chat/ChatContainer/components/VariablePopover.tsx index ea0582d16e..5d20f8c223 100644 --- a/projects/app/src/components/core/chat/ChatContainer/components/VariablePopover.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/components/VariablePopover.tsx @@ -105,7 +105,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { {...item} key={item.key} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} @@ -138,7 +138,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { {...item} key={item.key} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} @@ -157,7 +157,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { {...item} key={item.key} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx index dd78aa03c6..82505b35e8 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx @@ -285,7 +285,7 @@ export const useDebug = () => { label={item.label} required={item.required} description={t(item.description)} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} @@ -298,7 +298,7 @@ export const useDebug = () => { label={item.label} required={item.required} description={t(item.description)} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} @@ -311,7 +311,7 @@ export const useDebug = () => { label={item.label} required={item.required} description={item.description} - inputType={variableInputTypeToInputType(item.type)} + inputType={variableInputTypeToInputType(item.type, item.valueType)} form={variablesForm} fieldName={`variables.${item.key}`} bg={'myGray.50'} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx index a5eeb8e373..f5753e357f 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx @@ -14,7 +14,8 @@ import { } from '@chakra-ui/react'; import { VariableInputEnum, - WorkflowIOValueTypeEnum + WorkflowIOValueTypeEnum, + textInputVariableValueTypes } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeInputTypeEnum, @@ -198,12 +199,21 @@ const InputTypeConfig = ({ value: item.value })); - const showValueTypeSelect = - inputType === FlowNodeInputTypeEnum.reference || + const isVariableTextInput = type === 'variable' && inputType === VariableInputEnum.input; + + const isDynamicValueTypeInput = inputType === FlowNodeInputTypeEnum.customVariable || inputType === FlowNodeInputTypeEnum.hidden || inputType === VariableInputEnum.custom || - inputType === VariableInputEnum.internal; + inputType === VariableInputEnum.internal || + isVariableTextInput; + + const showValueTypeSelect = + inputType === FlowNodeInputTypeEnum.reference || isDynamicValueTypeInput; + + const valueTypeOptionList = isVariableTextInput + ? valueTypeSelectList.filter((item) => textInputVariableValueTypes.includes(item.value)) + : valueTypeSelectList.filter((item) => item.value !== WorkflowIOValueTypeEnum.arrayAny); const showRequired = useMemo(() => { const list = [ @@ -407,12 +417,13 @@ const InputTypeConfig = ({ {showValueTypeSelect ? ( - list={valueTypeSelectList.filter( - (item) => item.value !== WorkflowIOValueTypeEnum.arrayAny - )} + list={valueTypeOptionList} value={valueType} onChange={(e) => { setValue('valueType', e); + if (isVariableTextInput) { + setValue('defaultValue', ''); + } }} /> @@ -557,11 +568,7 @@ const InputTypeConfig = ({ {(inputType === FlowNodeInputTypeEnum.numberInput || - ((inputType === VariableInputEnum.custom || - inputType === VariableInputEnum.internal || - inputType === FlowNodeInputTypeEnum.customVariable || - inputType === FlowNodeInputTypeEnum.hidden) && - valueType === WorkflowIOValueTypeEnum.number)) && ( + (isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.number)) && ( )} - {(inputType === FlowNodeInputTypeEnum.input || - ((inputType === VariableInputEnum.custom || - inputType === VariableInputEnum.internal || - inputType === FlowNodeInputTypeEnum.customVariable || - inputType === FlowNodeInputTypeEnum.hidden) && - valueType === WorkflowIOValueTypeEnum.string)) && ( + {((inputType === FlowNodeInputTypeEnum.input && !isVariableTextInput) || + (isDynamicValueTypeInput && + (valueType === WorkflowIOValueTypeEnum.string || + valueType === WorkflowIOValueTypeEnum.any))) && ( setValue('defaultValue', e.target.value)} @@ -589,14 +594,12 @@ const InputTypeConfig = ({ /> )} {(inputType === FlowNodeInputTypeEnum.JSONEditor || - ((inputType === VariableInputEnum.custom || - inputType === VariableInputEnum.internal || - inputType === FlowNodeInputTypeEnum.customVariable || - inputType === FlowNodeInputTypeEnum.hidden) && + (isDynamicValueTypeInput && ![ WorkflowIOValueTypeEnum.number, WorkflowIOValueTypeEnum.string, - WorkflowIOValueTypeEnum.boolean + WorkflowIOValueTypeEnum.boolean, + WorkflowIOValueTypeEnum.any ].includes(valueType))) && ( )} {(inputType === FlowNodeInputTypeEnum.switch || - ((inputType === VariableInputEnum.custom || - inputType === VariableInputEnum.internal || - inputType === FlowNodeInputTypeEnum.customVariable || - inputType === FlowNodeInputTypeEnum.hidden) && - valueType === WorkflowIOValueTypeEnum.boolean)) && ( + (isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.boolean)) && ( diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 0a40575f02..3f217c7ab7 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -19,7 +19,9 @@ import { type EditorVariablePickerType } from '@fastgpt/web/components/common/Te import { formatEditorVariablePickerIcon, getAppChatConfig, - getHandleId + getHandleId, + isValidReferenceValue, + isValidReferenceValueFormat } from '@fastgpt/global/core/workflow/utils'; import { type TFunction } from 'next-i18next'; import { @@ -30,6 +32,7 @@ import { import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; +import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; import { type AppChatConfigType } from '@fastgpt/global/core/app/type'; import { cloneDeep, isEqual } from 'lodash'; import { workflowSystemVariables } from '../app/utils'; @@ -545,6 +548,53 @@ export const checkWorkflowNodeAndConnection = ({ return [data.nodeId]; } } + if (data.flowNodeType === FlowNodeTypeEnum.variableUpdate) { + const updateList: TUpdateListItem[] = inputs.find( + (input) => input.key === NodeInputKeyEnum.updateList + )?.value; + const nodeIds = nodes.map((n) => n.data.nodeId); + const isLiveReference = (value: ReferenceItemValueType | undefined) => { + if (!isValidReferenceValueFormat(value)) return false; + const [refNodeId, refOutputId] = value; + if (!refNodeId || !refOutputId) return false; + if (refNodeId === VARIABLE_NODE_ID) return true; + return !!nodes + .find((node) => node.data.nodeId === refNodeId) + ?.data.outputs.find((output) => output.id === refOutputId); + }; + if ( + !updateList || + updateList.length === 0 || + updateList.some((item) => { + if (!isValidReferenceValue(item.variable, nodeIds) || !isLiveReference(item.variable)) + return true; + + const isArrayVar = + typeof item.valueType === 'string' && item.valueType.startsWith('array'); + + if (item.renderType === FlowNodeInputTypeEnum.reference) { + if (isArrayVar) { + return ( + !Array.isArray(item.value) || + item.value.length === 0 || + (item.value as ReferenceItemValueType[]).some((v) => !isLiveReference(v)) + ); + } + return !isLiveReference(item.value as ReferenceItemValueType); + } + + // input mode: clear 不需要 value;boolean 由 booleanMode 决定 + if (isArrayVar && item.arrayMode === 'clear') return false; + if (item.valueType === WorkflowIOValueTypeEnum.boolean) return false; + const inputVal = item.value?.[1]; + return inputVal === undefined || inputVal === null || inputVal === ''; + }) + ) { + return [data.nodeId]; + } else { + continue; + } + } if ( inputs.some((input) => { diff --git a/projects/app/test/components/core/app/VariableEditModal/utils.test.ts b/projects/app/test/components/core/app/VariableEditModal/utils.test.ts new file mode 100644 index 0000000000..ae6070dabd --- /dev/null +++ b/projects/app/test/components/core/app/VariableEditModal/utils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { snapTextInputValueType } from '@/components/core/app/VariableEditModal/utils'; + +describe('snapTextInputValueType', () => { + it('undefined → string,不清空 defaultValue(legacy 隐式 string)', () => { + expect(snapTextInputValueType(undefined)).toEqual({ + valueType: WorkflowIOValueTypeEnum.string, + resetDefault: false + }); + }); + + it.each([ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayNumber, + WorkflowIOValueTypeEnum.arrayBoolean, + WorkflowIOValueTypeEnum.arrayObject + ])('合法 valueType %s 原样返回,不清 defaultValue', (valueType) => { + expect(snapTextInputValueType(valueType)).toEqual({ + valueType, + resetDefault: false + }); + }); + + it.each([ + WorkflowIOValueTypeEnum.number, + WorkflowIOValueTypeEnum.boolean, + WorkflowIOValueTypeEnum.any, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.chatHistory, + WorkflowIOValueTypeEnum.datasetQuote + ])('非法 valueType %s snap 回 string,并要求清 defaultValue', (valueType) => { + expect(snapTextInputValueType(valueType)).toEqual({ + valueType: WorkflowIOValueTypeEnum.string, + resetDefault: true + }); + }); +}); diff --git a/projects/app/test/components/core/app/formRender/utils.test.ts b/projects/app/test/components/core/app/formRender/utils.test.ts new file mode 100644 index 0000000000..4480308661 --- /dev/null +++ b/projects/app/test/components/core/app/formRender/utils.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { + VariableInputEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { + variableInputTypeToInputType, + valueTypeToInputType, + nodeInputTypeToInputType, + secretInputTypeToInputType +} from '@/components/core/app/formRender/utils'; +import { InputTypeEnum } from '@/components/core/app/formRender/constant'; + +describe('variableInputTypeToInputType', () => { + it('input + string → input', () => { + expect( + variableInputTypeToInputType(VariableInputEnum.input, WorkflowIOValueTypeEnum.string) + ).toBe(InputTypeEnum.input); + }); + + it('input + object → JSONEditor', () => { + expect( + variableInputTypeToInputType(VariableInputEnum.input, WorkflowIOValueTypeEnum.object) + ).toBe(InputTypeEnum.JSONEditor); + }); + + it('input + arrayString → JSONEditor', () => { + expect( + variableInputTypeToInputType(VariableInputEnum.input, WorkflowIOValueTypeEnum.arrayString) + ).toBe(InputTypeEnum.JSONEditor); + }); + + it('input + any → textarea(与 chat 侧保持)', () => { + expect(variableInputTypeToInputType(VariableInputEnum.input, WorkflowIOValueTypeEnum.any)).toBe( + InputTypeEnum.textarea + ); + }); + + it('input + undefined(legacy)→ input', () => { + expect(variableInputTypeToInputType(VariableInputEnum.input)).toBe(InputTypeEnum.input); + }); + + it('numberInput/switch 等固定映射不受 valueType 影响', () => { + expect( + variableInputTypeToInputType(VariableInputEnum.numberInput, WorkflowIOValueTypeEnum.string) + ).toBe(InputTypeEnum.numberInput); + expect(variableInputTypeToInputType(VariableInputEnum.switch)).toBe(InputTypeEnum.switch); + }); + + it('custom/internal 根据 valueType 决定', () => { + expect( + variableInputTypeToInputType(VariableInputEnum.custom, WorkflowIOValueTypeEnum.object) + ).toBe(InputTypeEnum.JSONEditor); + expect( + variableInputTypeToInputType(VariableInputEnum.internal, WorkflowIOValueTypeEnum.number) + ).toBe(InputTypeEnum.numberInput); + }); +}); + +describe('valueTypeToInputType', () => { + it('string/number/boolean → input/numberInput/switch', () => { + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.string)).toBe(InputTypeEnum.input); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.number)).toBe(InputTypeEnum.numberInput); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.boolean)).toBe(InputTypeEnum.switch); + }); + + it('object/array* → JSONEditor', () => { + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.object)).toBe(InputTypeEnum.JSONEditor); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.arrayString)).toBe( + InputTypeEnum.JSONEditor + ); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.arrayObject)).toBe( + InputTypeEnum.JSONEditor + ); + }); + + it('any/undefined → textarea', () => { + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.any)).toBe(InputTypeEnum.textarea); + expect(valueTypeToInputType(undefined)).toBe(InputTypeEnum.textarea); + }); + + it('chatHistory/datasetQuote/dynamic/selectDataset/selectApp → JSONEditor', () => { + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.chatHistory)).toBe( + InputTypeEnum.JSONEditor + ); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.datasetQuote)).toBe( + InputTypeEnum.JSONEditor + ); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.dynamic)).toBe(InputTypeEnum.JSONEditor); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.selectDataset)).toBe( + InputTypeEnum.JSONEditor + ); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.selectApp)).toBe(InputTypeEnum.JSONEditor); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.arrayAny)).toBe(InputTypeEnum.JSONEditor); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.arrayNumber)).toBe( + InputTypeEnum.JSONEditor + ); + expect(valueTypeToInputType(WorkflowIOValueTypeEnum.arrayBoolean)).toBe( + InputTypeEnum.JSONEditor + ); + }); +}); + +describe('nodeInputTypeToInputType', () => { + it('跳过 reference,取到真正的控件类型', () => { + expect( + nodeInputTypeToInputType([FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.input]) + ).toBe(InputTypeEnum.input); + }); + + it.each([ + [FlowNodeInputTypeEnum.input, InputTypeEnum.input], + [FlowNodeInputTypeEnum.textarea, InputTypeEnum.textarea], + [FlowNodeInputTypeEnum.password, InputTypeEnum.password], + [FlowNodeInputTypeEnum.numberInput, InputTypeEnum.numberInput], + [FlowNodeInputTypeEnum.switch, InputTypeEnum.switch], + [FlowNodeInputTypeEnum.select, InputTypeEnum.select], + [FlowNodeInputTypeEnum.multipleSelect, InputTypeEnum.multipleSelect], + [FlowNodeInputTypeEnum.JSONEditor, InputTypeEnum.JSONEditor], + [FlowNodeInputTypeEnum.selectLLMModel, InputTypeEnum.selectLLMModel], + [FlowNodeInputTypeEnum.fileSelect, InputTypeEnum.fileSelect], + [FlowNodeInputTypeEnum.timePointSelect, InputTypeEnum.timePointSelect], + [FlowNodeInputTypeEnum.timeRangeSelect, InputTypeEnum.timeRangeSelect] + ])('%s → %s', (input, expected) => { + expect(nodeInputTypeToInputType([input])).toBe(expected); + }); + + it('空数组或未识别类型 → textarea', () => { + expect(nodeInputTypeToInputType([])).toBe(InputTypeEnum.textarea); + expect(nodeInputTypeToInputType()).toBe(InputTypeEnum.textarea); + expect(nodeInputTypeToInputType([FlowNodeInputTypeEnum.reference])).toBe( + InputTypeEnum.textarea + ); + }); +}); + +describe('secretInputTypeToInputType', () => { + it.each([ + ['input', InputTypeEnum.input], + ['numberInput', InputTypeEnum.numberInput], + ['switch', InputTypeEnum.switch], + ['select', InputTypeEnum.select] + ] as const)('%s → %s', (input, expected) => { + expect(secretInputTypeToInputType(input)).toBe(expected); + }); + + it('未识别 → textarea', () => { + expect(secretInputTypeToInputType('unknown' as any)).toBe(InputTypeEnum.textarea); + }); +}); diff --git a/projects/app/test/web/core/app/workflow/utils.test.ts b/projects/app/test/web/core/app/workflow/utils.test.ts index 85d667c1f0..591c3804b0 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -1,4 +1,4 @@ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import type { FlowNodeItemType, FlowNodeTemplateType, @@ -21,6 +21,7 @@ import { checkWorkflowNodeAndConnection } from '@/web/core/workflow/utils'; import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; +import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants'; describe('nodeTemplate2FlowNode', () => { it('should convert template to flow node', () => { @@ -394,4 +395,163 @@ describe('checkWorkflowNodeAndConnection', () => { expect(result).toBeUndefined(); }); }); + describe('variableUpdate node', () => { + const makeVarUpdateNode = (updateList: any[]): Node => + ({ + id: 'u1', + type: FlowNodeTypeEnum.variableUpdate, + position: { x: 0, y: 0 }, + data: { + nodeId: 'u1', + flowNodeType: FlowNodeTypeEnum.variableUpdate, + name: 'update', + avatar: '', + inputs: [ + { + key: NodeInputKeyEnum.updateList, + valueType: WorkflowIOValueTypeEnum.any, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + value: updateList + } + ], + outputs: [], + version: '1', + intro: '' + } as any + }) as any; + + const startNode: Node = { + id: 's1', + type: FlowNodeTypeEnum.workflowStart, + position: { x: 0, y: 0 }, + data: { + nodeId: 's1', + flowNodeType: FlowNodeTypeEnum.workflowStart, + name: 'start', + avatar: '', + inputs: [], + outputs: [], + version: '1', + intro: '' + } as any + }; + + const connectedEdges: Edge[] = [{ id: 'e1', source: 's1', target: 'u1', type: EDGE_TYPE }]; + + const run = (updateList: any[]) => + checkWorkflowNodeAndConnection({ + nodes: [startNode, makeVarUpdateNode(updateList)], + edges: connectedEdges + }); + + it('flags empty updateList', () => { + expect(run([])).toEqual(['u1']); + }); + + it('flags row with empty variable', () => { + expect( + run([ + { + variable: ['', ''], + value: ['', 'hello'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toEqual(['u1']); + }); + + it('flags input row with empty value', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: ['', ''], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toEqual(['u1']); + }); + + it('passes when boolean input has no value (booleanMode decides)', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: undefined, + valueType: WorkflowIOValueTypeEnum.boolean, + booleanMode: 'true', + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toBeUndefined(); + }); + + it('passes when array clear mode has no value', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: undefined, + valueType: WorkflowIOValueTypeEnum.arrayString, + arrayMode: 'clear', + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toBeUndefined(); + }); + + it('flags reference row with incomplete value', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: ['n2', ''], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference + } + ]) + ).toEqual(['u1']); + }); + + it('flags reference array row with empty array', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: [], + valueType: WorkflowIOValueTypeEnum.arrayString, + renderType: FlowNodeInputTypeEnum.reference + } + ]) + ).toEqual(['u1']); + }); + + it('passes a fully filled input row', () => { + expect( + run([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + value: ['', 'hello'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toBeUndefined(); + }); + + it('flags variable pointing to a non-existent node id', () => { + expect( + run([ + { + variable: ['ghost-node', 'out1'], + value: ['', 'hello'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input + } + ]) + ).toEqual(['u1']); + }); + }); }); diff --git a/test/cases/global/core/workflow/utils.test.ts b/test/cases/global/core/workflow/utils.test.ts index 8cb50ac477..e58cf26cd9 100644 --- a/test/cases/global/core/workflow/utils.test.ts +++ b/test/cases/global/core/workflow/utils.test.ts @@ -753,6 +753,89 @@ describe('appData2FlowNodeIO', () => { expect(switchVar?.renderTypeList).toContain(FlowNodeInputTypeEnum.switch); }); + it('should map text input variable with non-string valueType to JSONEditor', () => { + const result = appData2FlowNodeIO({ + chatConfig: { + variables: [ + { + key: 'objVar', + label: 'Object', + type: VariableInputEnum.input, + description: '', + valueType: WorkflowIOValueTypeEnum.object + }, + { + key: 'arrVar', + label: 'Array', + type: VariableInputEnum.input, + description: '', + valueType: WorkflowIOValueTypeEnum.arrayString + }, + { + key: 'strVar', + label: 'String', + type: VariableInputEnum.input, + description: '', + valueType: WorkflowIOValueTypeEnum.string + } + ] + } + }); + + const objVar = result.inputs.find((i) => i.key === 'objVar'); + expect(objVar?.renderTypeList).toEqual([ + FlowNodeInputTypeEnum.JSONEditor, + FlowNodeInputTypeEnum.reference + ]); + + const arrVar = result.inputs.find((i) => i.key === 'arrVar'); + expect(arrVar?.renderTypeList).toEqual([ + FlowNodeInputTypeEnum.JSONEditor, + FlowNodeInputTypeEnum.reference + ]); + + const strVar = result.inputs.find((i) => i.key === 'strVar'); + expect(strVar?.renderTypeList).toEqual([ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference + ]); + }); + + it('should snap legacy any valueType to string and keep undefined as text input', () => { + const result = appData2FlowNodeIO({ + chatConfig: { + variables: [ + { + key: 'anyVar', + label: 'Any', + type: VariableInputEnum.input, + description: '', + valueType: WorkflowIOValueTypeEnum.any + }, + { + key: 'legacyVar', + label: 'Legacy', + type: VariableInputEnum.input, + description: '' + } + ] + } + }); + + const anyVar = result.inputs.find((i) => i.key === 'anyVar'); + expect(anyVar?.renderTypeList).toEqual([ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference + ]); + expect(anyVar?.valueType).toBe(WorkflowIOValueTypeEnum.string); + + const legacyVar = result.inputs.find((i) => i.key === 'legacyVar'); + expect(legacyVar?.renderTypeList).toEqual([ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference + ]); + }); + it('should preserve defaultValue on variable inputs', () => { const result = appData2FlowNodeIO({ chatConfig: {