feat: support multiple valueTypes for text input variables (#6801)

* 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>
This commit is contained in:
DigHuang
2026-04-24 22:01:29 +08:00
committed by GitHub
parent 27ebc0eeba
commit b7cdb3fb86
17 changed files with 599 additions and 47 deletions
@@ -5,6 +5,8 @@ description: 'FastGPT V4.15.0 更新说明'
## 🚀 新增内容
1. 新增循环节点,弃用旧的批量执行。
2. 全局变量输入框支持输入 object 类型数据。
## ⚙️ 优化
+1 -1
View File
@@ -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",
@@ -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[][] = [
[
{
+23 -3
View File
@@ -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>([
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, FlowNodeInputTypeEnum[]> = {
[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,
@@ -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
@@ -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 };
};
@@ -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;
@@ -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}`}
@@ -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'}
/>
@@ -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'}
@@ -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'}
@@ -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 ? (
<Box flex={1}>
<MySelect<WorkflowIOValueTypeEnum>
list={valueTypeSelectList.filter(
(item) => item.value !== WorkflowIOValueTypeEnum.arrayAny
)}
list={valueTypeOptionList}
value={valueType}
onChange={(e) => {
setValue('valueType', e);
if (isVariableTextInput) {
setValue('defaultValue', '');
}
}}
/>
</Box>
@@ -557,11 +568,7 @@ const InputTypeConfig = ({
</FormLabel>
<Flex flex={1} h={10}>
{(inputType === FlowNodeInputTypeEnum.numberInput ||
((inputType === VariableInputEnum.custom ||
inputType === VariableInputEnum.internal ||
inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden) &&
valueType === WorkflowIOValueTypeEnum.number)) && (
(isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.number)) && (
<MyNumberInput
value={defaultValue}
min={min ? min : undefined}
@@ -572,12 +579,10 @@ const InputTypeConfig = ({
}}
/>
)}
{(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))) && (
<MyTextarea
value={defaultValue}
onChange={(e) => 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))) && (
<JsonEditor
bg={'myGray.50'}
@@ -609,11 +612,7 @@ const InputTypeConfig = ({
/>
)}
{(inputType === FlowNodeInputTypeEnum.switch ||
((inputType === VariableInputEnum.custom ||
inputType === VariableInputEnum.internal ||
inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden) &&
valueType === WorkflowIOValueTypeEnum.boolean)) && (
(isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.boolean)) && (
<Flex h={10} alignItems={'center'}>
<Switch {...register('defaultValue')} />
</Flex>
+51 -1
View File
@@ -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 不需要 valueboolean 由 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) => {
@@ -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,不清空 defaultValuelegacy 隐式 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
});
});
});
@@ -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 + undefinedlegacy)→ 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);
});
});
@@ -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<FlowNodeItemType> =>
({
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<FlowNodeItemType> = {
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']);
});
});
});
@@ -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: {