mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-15 01:06:04 +08:00
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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user