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
@@ -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']);
});
});
});