Commit b7cdb3fb by DigHuang Committed by GitHub

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>
parent 27ebc0ee
...@@ -5,6 +5,8 @@ description: 'FastGPT V4.15.0 更新说明' ...@@ -5,6 +5,8 @@ description: 'FastGPT V4.15.0 更新说明'
## 🚀 新增内容 ## 🚀 新增内容
1. 新增循环节点,弃用旧的批量执行。
2. 全局变量输入框支持输入 object 类型数据。
## ⚙️ 优化 ## ⚙️ 优化
......
...@@ -230,7 +230,7 @@ ...@@ -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/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.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/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.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/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", "document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
......
...@@ -373,6 +373,15 @@ type VariableConfigType = { ...@@ -373,6 +373,15 @@ type VariableConfigType = {
description?: string; description?: string;
}; };
export const textInputVariableValueTypes: WorkflowIOValueTypeEnum[] = [
WorkflowIOValueTypeEnum.string,
WorkflowIOValueTypeEnum.object,
WorkflowIOValueTypeEnum.arrayString,
WorkflowIOValueTypeEnum.arrayNumber,
WorkflowIOValueTypeEnum.arrayBoolean,
WorkflowIOValueTypeEnum.arrayObject
];
export const variableConfigs: VariableConfigType[][] = [ export const variableConfigs: VariableConfigType[][] = [
[ [
{ {
......
...@@ -10,7 +10,8 @@ import { ...@@ -10,7 +10,8 @@ import {
VariableInputEnum, VariableInputEnum,
variableMap, variableMap,
VARIABLE_NODE_ID, VARIABLE_NODE_ID,
NodeOutputKeyEnum NodeOutputKeyEnum,
textInputVariableValueTypes
} from './constants'; } from './constants';
import { import {
type FlowNodeInputItemType, type FlowNodeInputItemType,
...@@ -234,6 +235,14 @@ export const pluginData2FlowNodeIO = ({ ...@@ -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 = ({ export const appData2FlowNodeIO = ({
chatConfig chatConfig
}: { }: {
...@@ -245,8 +254,19 @@ export const appData2FlowNodeIO = ({ ...@@ -245,8 +254,19 @@ export const appData2FlowNodeIO = ({
const variableInput = !chatConfig?.variables const variableInput = !chatConfig?.variables
? [] ? []
: chatConfig.variables.map((item) => { : 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[]> = { const renderTypeMap: Record<VariableInputEnum, FlowNodeInputTypeEnum[]> = {
[VariableInputEnum.input]: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], [VariableInputEnum.input]: isJsonValueType
? [FlowNodeInputTypeEnum.JSONEditor, FlowNodeInputTypeEnum.reference]
: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference],
[VariableInputEnum.textarea]: [ [VariableInputEnum.textarea]: [
FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.textarea,
FlowNodeInputTypeEnum.reference FlowNodeInputTypeEnum.reference
...@@ -271,7 +291,7 @@ export const appData2FlowNodeIO = ({ ...@@ -271,7 +291,7 @@ export const appData2FlowNodeIO = ({
label: item.label, label: item.label,
debugLabel: item.label, debugLabel: item.label,
description: '', description: '',
valueType: item.valueType || WorkflowIOValueTypeEnum.any, valueType: normalizedValueType || WorkflowIOValueTypeEnum.any,
required: item.required, required: item.required,
defaultValue: item.defaultValue, defaultValue: item.defaultValue,
value: item.defaultValue, value: item.defaultValue,
......
...@@ -14,6 +14,7 @@ import InputTypeSelector from '@fastgpt/web/components/common/InputTypeSelector' ...@@ -14,6 +14,7 @@ import InputTypeSelector from '@fastgpt/web/components/common/InputTypeSelector'
import { getVariableInputTypeList } from '@fastgpt/web/components/common/InputTypeSelector/configs'; import { getVariableInputTypeList } from '@fastgpt/web/components/common/InputTypeSelector/configs';
import { addVariable } from '../VariableEdit'; import { addVariable } from '../VariableEdit';
import { useValidateFieldName, useSubmitErrorHandler } from '../utils/formValidation'; import { useValidateFieldName, useSubmitErrorHandler } from '../utils/formValidation';
import { snapTextInputValueType } from './utils';
const VariableEditModal = ({ const VariableEditModal = ({
onClose, onClose,
...@@ -38,7 +39,14 @@ const VariableEditModal = ({ ...@@ -38,7 +39,14 @@ const VariableEditModal = ({
const type = watch('type'); const type = watch('type');
useEffect(() => { useEffect(() => {
reset(variable); 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(), []); const inputTypeList = useMemo(() => getVariableInputTypeList(), []);
...@@ -67,6 +75,13 @@ const VariableEditModal = ({ ...@@ -67,6 +75,13 @@ const VariableEditModal = ({
if (typeEnum === VariableInputEnum.file) { if (typeEnum === VariableInputEnum.file) {
setValue('canLocalUpload', true); setValue('canLocalUpload', true);
} }
if (typeEnum === VariableInputEnum.input) {
const snap = snapTextInputValueType(value.valueType);
if (snap.resetDefault) {
setValue('valueType', snap.valueType);
setValue('defaultValue', '');
}
}
setValue('type', typeEnum); setValue('type', typeEnum);
}, },
...@@ -88,16 +103,17 @@ const VariableEditModal = ({ ...@@ -88,16 +103,17 @@ const VariableEditModal = ({
return; return;
} }
// For custom and internal types, user can select valueType manually, so don't override it // custom/internal/input 允许用户自选 valueType,其余强制用模板 defaultValueType
// For other types, set valueType from defaultValueType
if ( if (
![VariableInputEnum.custom, VariableInputEnum.internal, VariableInputEnum].includes( ![VariableInputEnum.custom, VariableInputEnum.internal, VariableInputEnum.input].includes(
data.type data.type
) )
) { ) {
data.valueType = inputTypeList data.valueType = inputTypeList
.flat() .flat()
.find((item) => item.value === data.type)?.defaultValueType; .find((item) => item.value === data.type)?.defaultValueType;
} else if (data.type === VariableInputEnum.input) {
data.valueType = snapTextInputValueType(data.valueType).valueType;
} }
// Special types set required = false // Special types set required = false
......
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 = ( ...@@ -10,7 +10,9 @@ export const variableInputTypeToInputType = (
inputType: VariableInputEnum, inputType: VariableInputEnum,
valueType?: WorkflowIOValueTypeEnum 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.textarea) return InputTypeEnum.textarea;
if (inputType === VariableInputEnum.numberInput) return InputTypeEnum.numberInput; if (inputType === VariableInputEnum.numberInput) return InputTypeEnum.numberInput;
if (inputType === VariableInputEnum.select) return InputTypeEnum.select; if (inputType === VariableInputEnum.select) return InputTypeEnum.select;
......
...@@ -193,7 +193,7 @@ const VariableInputForm = ({ ...@@ -193,7 +193,7 @@ const VariableInputForm = ({
isUnChange={isUnChange} isUnChange={isUnChange}
key={item.key} key={item.key}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
bg={'myGray.50'} bg={'myGray.50'}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
......
...@@ -64,7 +64,7 @@ const ChatHomeVariablesForm = ({ chatForm }: Props) => { ...@@ -64,7 +64,7 @@ const ChatHomeVariablesForm = ({ chatForm }: Props) => {
key={item.key} key={item.key}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
bg={'myGray.50'} bg={'myGray.50'}
/> />
......
...@@ -105,7 +105,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { ...@@ -105,7 +105,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => {
{...item} {...item}
key={item.key} key={item.key}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
...@@ -138,7 +138,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { ...@@ -138,7 +138,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => {
{...item} {...item}
key={item.key} key={item.key}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
...@@ -157,7 +157,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => { ...@@ -157,7 +157,7 @@ const VariablePopover = ({ chatType }: { chatType: ChatTypeEnum }) => {
{...item} {...item}
key={item.key} key={item.key}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
......
...@@ -285,7 +285,7 @@ export const useDebug = () => { ...@@ -285,7 +285,7 @@ export const useDebug = () => {
label={item.label} label={item.label}
required={item.required} required={item.required}
description={t(item.description)} description={t(item.description)}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
...@@ -298,7 +298,7 @@ export const useDebug = () => { ...@@ -298,7 +298,7 @@ export const useDebug = () => {
label={item.label} label={item.label}
required={item.required} required={item.required}
description={t(item.description)} description={t(item.description)}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
...@@ -311,7 +311,7 @@ export const useDebug = () => { ...@@ -311,7 +311,7 @@ export const useDebug = () => {
label={item.label} label={item.label}
required={item.required} required={item.required}
description={item.description} description={item.description}
inputType={variableInputTypeToInputType(item.type)} inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm} form={variablesForm}
fieldName={`variables.${item.key}`} fieldName={`variables.${item.key}`}
bg={'myGray.50'} bg={'myGray.50'}
......
...@@ -14,7 +14,8 @@ import { ...@@ -14,7 +14,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import {
VariableInputEnum, VariableInputEnum,
WorkflowIOValueTypeEnum WorkflowIOValueTypeEnum,
textInputVariableValueTypes
} from '@fastgpt/global/core/workflow/constants'; } from '@fastgpt/global/core/workflow/constants';
import { import {
FlowNodeInputTypeEnum, FlowNodeInputTypeEnum,
...@@ -198,12 +199,21 @@ const InputTypeConfig = ({ ...@@ -198,12 +199,21 @@ const InputTypeConfig = ({
value: item.value value: item.value
})); }));
const showValueTypeSelect = const isVariableTextInput = type === 'variable' && inputType === VariableInputEnum.input;
inputType === FlowNodeInputTypeEnum.reference ||
const isDynamicValueTypeInput =
inputType === FlowNodeInputTypeEnum.customVariable || inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden || inputType === FlowNodeInputTypeEnum.hidden ||
inputType === VariableInputEnum.custom || 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 showRequired = useMemo(() => {
const list = [ const list = [
...@@ -407,12 +417,13 @@ const InputTypeConfig = ({ ...@@ -407,12 +417,13 @@ const InputTypeConfig = ({
{showValueTypeSelect ? ( {showValueTypeSelect ? (
<Box flex={1}> <Box flex={1}>
<MySelect<WorkflowIOValueTypeEnum> <MySelect<WorkflowIOValueTypeEnum>
list={valueTypeSelectList.filter( list={valueTypeOptionList}
(item) => item.value !== WorkflowIOValueTypeEnum.arrayAny
)}
value={valueType} value={valueType}
onChange={(e) => { onChange={(e) => {
setValue('valueType', e); setValue('valueType', e);
if (isVariableTextInput) {
setValue('defaultValue', '');
}
}} }}
/> />
</Box> </Box>
...@@ -557,11 +568,7 @@ const InputTypeConfig = ({ ...@@ -557,11 +568,7 @@ const InputTypeConfig = ({
</FormLabel> </FormLabel>
<Flex flex={1} h={10}> <Flex flex={1} h={10}>
{(inputType === FlowNodeInputTypeEnum.numberInput || {(inputType === FlowNodeInputTypeEnum.numberInput ||
((inputType === VariableInputEnum.custom || (isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.number)) && (
inputType === VariableInputEnum.internal ||
inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden) &&
valueType === WorkflowIOValueTypeEnum.number)) && (
<MyNumberInput <MyNumberInput
value={defaultValue} value={defaultValue}
min={min ? min : undefined} min={min ? min : undefined}
...@@ -572,12 +579,10 @@ const InputTypeConfig = ({ ...@@ -572,12 +579,10 @@ const InputTypeConfig = ({
}} }}
/> />
)} )}
{(inputType === FlowNodeInputTypeEnum.input || {((inputType === FlowNodeInputTypeEnum.input && !isVariableTextInput) ||
((inputType === VariableInputEnum.custom || (isDynamicValueTypeInput &&
inputType === VariableInputEnum.internal || (valueType === WorkflowIOValueTypeEnum.string ||
inputType === FlowNodeInputTypeEnum.customVariable || valueType === WorkflowIOValueTypeEnum.any))) && (
inputType === FlowNodeInputTypeEnum.hidden) &&
valueType === WorkflowIOValueTypeEnum.string)) && (
<MyTextarea <MyTextarea
value={defaultValue} value={defaultValue}
onChange={(e) => setValue('defaultValue', e.target.value)} onChange={(e) => setValue('defaultValue', e.target.value)}
...@@ -589,14 +594,12 @@ const InputTypeConfig = ({ ...@@ -589,14 +594,12 @@ const InputTypeConfig = ({
/> />
)} )}
{(inputType === FlowNodeInputTypeEnum.JSONEditor || {(inputType === FlowNodeInputTypeEnum.JSONEditor ||
((inputType === VariableInputEnum.custom || (isDynamicValueTypeInput &&
inputType === VariableInputEnum.internal ||
inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden) &&
![ ![
WorkflowIOValueTypeEnum.number, WorkflowIOValueTypeEnum.number,
WorkflowIOValueTypeEnum.string, WorkflowIOValueTypeEnum.string,
WorkflowIOValueTypeEnum.boolean WorkflowIOValueTypeEnum.boolean,
WorkflowIOValueTypeEnum.any
].includes(valueType))) && ( ].includes(valueType))) && (
<JsonEditor <JsonEditor
bg={'myGray.50'} bg={'myGray.50'}
...@@ -609,11 +612,7 @@ const InputTypeConfig = ({ ...@@ -609,11 +612,7 @@ const InputTypeConfig = ({
/> />
)} )}
{(inputType === FlowNodeInputTypeEnum.switch || {(inputType === FlowNodeInputTypeEnum.switch ||
((inputType === VariableInputEnum.custom || (isDynamicValueTypeInput && valueType === WorkflowIOValueTypeEnum.boolean)) && (
inputType === VariableInputEnum.internal ||
inputType === FlowNodeInputTypeEnum.customVariable ||
inputType === FlowNodeInputTypeEnum.hidden) &&
valueType === WorkflowIOValueTypeEnum.boolean)) && (
<Flex h={10} alignItems={'center'}> <Flex h={10} alignItems={'center'}>
<Switch {...register('defaultValue')} /> <Switch {...register('defaultValue')} />
</Flex> </Flex>
......
...@@ -19,7 +19,9 @@ import { type EditorVariablePickerType } from '@fastgpt/web/components/common/Te ...@@ -19,7 +19,9 @@ import { type EditorVariablePickerType } from '@fastgpt/web/components/common/Te
import { import {
formatEditorVariablePickerIcon, formatEditorVariablePickerIcon,
getAppChatConfig, getAppChatConfig,
getHandleId getHandleId,
isValidReferenceValue,
isValidReferenceValueFormat
} from '@fastgpt/global/core/workflow/utils'; } from '@fastgpt/global/core/workflow/utils';
import { type TFunction } from 'next-i18next'; import { type TFunction } from 'next-i18next';
import { import {
...@@ -30,6 +32,7 @@ import { ...@@ -30,6 +32,7 @@ import {
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; 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 { type AppChatConfigType } from '@fastgpt/global/core/app/type';
import { cloneDeep, isEqual } from 'lodash'; import { cloneDeep, isEqual } from 'lodash';
import { workflowSystemVariables } from '../app/utils'; import { workflowSystemVariables } from '../app/utils';
...@@ -545,6 +548,53 @@ export const checkWorkflowNodeAndConnection = ({ ...@@ -545,6 +548,53 @@ export const checkWorkflowNodeAndConnection = ({
return [data.nodeId]; 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 ( if (
inputs.some((input) => { inputs.some((input) => {
......
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
});
});
});
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);
});
});
import { vi, describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { import type {
FlowNodeItemType, FlowNodeItemType,
FlowNodeTemplateType, FlowNodeTemplateType,
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
checkWorkflowNodeAndConnection checkWorkflowNodeAndConnection
} from '@/web/core/workflow/utils'; } from '@/web/core/workflow/utils';
import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io';
import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
describe('nodeTemplate2FlowNode', () => { describe('nodeTemplate2FlowNode', () => {
it('should convert template to flow node', () => { it('should convert template to flow node', () => {
...@@ -394,4 +395,163 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -394,4 +395,163 @@ describe('checkWorkflowNodeAndConnection', () => {
expect(result).toBeUndefined(); 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', () => { ...@@ -753,6 +753,89 @@ describe('appData2FlowNodeIO', () => {
expect(switchVar?.renderTypeList).toContain(FlowNodeInputTypeEnum.switch); 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', () => { it('should preserve defaultValue on variable inputs', () => {
const result = appData2FlowNodeIO({ const result = appData2FlowNodeIO({
chatConfig: { chatConfig: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment