Files
EasyFlow/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.test.ts
陈子默 1c0fbfa5ff fix: 修复工作流入口标题与提示词校验
- 支持系统入口标题清空占位与下游引用展示同步

- 降低字段编辑触发的节点图重算和无效渲染

- 在保存及执行前拦截空白的大模型用户提示词
2026-08-10 23:15:41 +08:00

1364 lines
37 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Edge, Node } from '@xyflow/svelte';
import {
appendStartFormField,
buildAutoBindingPatch,
buildSequentialFieldBindingPatches,
buildFieldBindingPatch,
buildEditorReferenceParameters,
buildSingleRunModel,
buildSingleRunParameters,
createInitialWorkflowData,
ensureStartNodeParameters,
FIELD_BINDING_META_KEY,
isStartFormFieldKeyAvailable,
normalizeStartNodeData,
normalizeWorkflowStartNodes,
renameStartFieldReferencesInNodes,
removeStartFormField,
updateStartFormField,
} from './workflowNodeFields';
describe('workflow node fields', () => {
it('creates initial workflow data with fixed start input', () => {
const initial = createInitialWorkflowData();
expect(initial.nodes).toHaveLength(1);
expect(initial.nodes[0]?.type).toBe('startNode');
const parameters = ensureStartNodeParameters(
(initial.nodes[0]?.data?.parameters || []) as any[],
);
expect(parameters).toHaveLength(1);
expect(parameters[0]?.name).toBe('user_input');
expect(parameters[0]?.systemReserved).toBe(true);
expect(parameters[0]?.required).toBe(true);
expect(parameters[0]?.formLabel).toBe('');
expect(parameters[0]?.displayName).toBe('流程开始 > 用户问题');
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
title: '开始问答',
submitText: '开始',
});
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
key: 'user_input',
label: '',
type: 'textarea',
systemReserved: true,
required: true,
});
});
it('preserves an empty system start title for the default placeholder', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const customized = updateStartFormField(startData, 'user_input', {
label: '反洗钱',
});
const cleared = updateStartFormField(customized, 'user_input', {
label: '',
});
expect(customized.startFormSchema?.[0]?.label).toBe('反洗钱');
expect(cleared.startFormSchema?.[0]?.label).toBe('');
expect((cleared.parameters as any[])?.[0]?.formLabel).toBe('');
expect((cleared.parameters as any[])?.[0]?.displayName).toBe(
'流程开始 > 用户问题',
);
});
it('preserves an explicitly entered default or prefixed system title', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const defaultTitle = updateStartFormField(startData, 'user_input', {
label: '用户问题',
});
const prefixedTitle = updateStartFormField(startData, 'user_input', {
label: '用户问题说明',
});
expect(defaultTitle.startFormSchema?.[0]?.label).toBe('用户问题');
expect((defaultTitle.parameters as any[])?.[0]?.formLabel).toBe('用户问题');
expect(prefixedTitle.startFormSchema?.[0]?.label).toBe('用户问题说明');
expect((prefixedTitle.parameters as any[])?.[0]?.formLabel).toBe(
'用户问题说明',
);
});
it('appends custom start form field into schema source of truth', () => {
const initial = createInitialWorkflowData();
const startNode = initial.nodes[0]!;
const nextData = appendStartFormField(
startNode.data as Record<string, any>,
{
type: 'select',
options: ['售前', '售后'],
},
);
expect(nextData.startFormSchema).toHaveLength(2);
expect(nextData.startFormSchema?.[1]).toMatchObject({
key: 'select_field',
label: '下拉字段',
type: 'select',
options: ['售前', '售后'],
});
expect((nextData.parameters as any[])?.[1]).toMatchObject({
name: 'select_field',
formLabel: '下拉字段',
formType: 'select',
enums: ['售前', '售后'],
});
});
it('updates generated field key when switching field type', () => {
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
type: 'text',
},
);
const updated = updateStartFormField(appended, 'text_field', {
type: 'file',
placeholder: '请选择文件',
});
expect(
updated.startFormSchema?.find((item: any) => item.key === 'file_field'),
).toMatchObject({
key: 'file_field',
label: '文件字段',
type: 'file',
contentType: 'file',
placeholder: '请选择文件',
});
expect(
(updated.parameters as any[]).find((item) => item.name === 'file_field'),
).toMatchObject({
name: 'file_field',
dataType: 'File',
contentType: 'file',
});
});
it('updates and removes custom start form fields through schema source of truth', () => {
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
key: 'attachments',
label: '附件',
type: 'text',
placeholder: '请输入内容',
},
);
const updated = updateStartFormField(appended, 'attachments', {
type: 'file',
placeholder: '请选择文件',
});
expect(
updated.startFormSchema?.find((item: any) => item.key === 'attachments'),
).toMatchObject({
type: 'file',
contentType: 'file',
placeholder: '请选择文件',
});
expect(
(updated.parameters as any[]).find((item) => item.name === 'attachments'),
).toMatchObject({
dataType: 'File',
contentType: 'file',
});
const removed = removeStartFormField(updated, 'attachments');
expect(removed.startFormSchema).toHaveLength(1);
expect((removed.parameters as any[]).map((item) => item.name)).toEqual([
'user_input',
]);
});
it('keeps custom start field parameter id stable when renaming key', () => {
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
type: 'text',
},
);
const previousField = appended.startFormSchema?.find(
(item: any) => item.key === 'text_field',
);
const previousParameter = (appended.parameters as any[]).find(
(item) => item.name === 'text_field',
);
const updated = updateStartFormField(appended, 'text_field', {
key: 'topic',
label: '主题',
});
const nextField = updated.startFormSchema?.find(
(item: any) => item.key === 'topic',
);
const nextParameter = (updated.parameters as any[]).find(
(item) => item.name === 'topic',
);
expect(previousField?.id).toBeTruthy();
expect(nextField?.id).toBe(previousField?.id);
expect(nextParameter?.id).toBe(previousParameter?.id);
});
it('aligns the default field label when renaming a generated key', () => {
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
type: 'text',
},
);
const updated = updateStartFormField(appended, 'text_field', {
key: '补充信息',
});
expect(
updated.startFormSchema?.find((item: any) => item.key === '补充信息'),
).toMatchObject({
key: '补充信息',
label: '补充信息',
});
expect(
(updated.parameters as any[]).find((item) => item.name === '补充信息'),
).toMatchObject({
name: '补充信息',
formLabel: '补充信息',
});
});
it('keeps start fields unchanged when renaming to an existing key', () => {
const initial = createInitialWorkflowData();
const withTopic = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
key: 'topic',
label: '主题',
type: 'text',
},
);
const withDetails = appendStartFormField(withTopic, {
key: 'details',
label: '详情',
type: 'textarea',
});
const previousFields = withDetails.startFormSchema;
const previousParameters = withDetails.parameters;
expect(
isStartFormFieldKeyAvailable(withDetails, 'details', 'summary'),
).toBe(true);
expect(isStartFormFieldKeyAvailable(withDetails, 'details', 'topic')).toBe(
false,
);
expect(
isStartFormFieldKeyAvailable(withDetails, 'details', 'user_input'),
).toBe(false);
const duplicatedCustomKey = updateStartFormField(withDetails, 'details', {
key: 'topic',
});
const duplicatedSystemKey = updateStartFormField(withDetails, 'details', {
key: 'user_input',
});
expect(duplicatedCustomKey.startFormSchema).toEqual(previousFields);
expect(duplicatedCustomKey.parameters).toEqual(previousParameters);
expect(duplicatedSystemKey.startFormSchema).toEqual(previousFields);
expect(duplicatedSystemKey.parameters).toEqual(previousParameters);
});
it('renames downstream token and managed references when start field key changes', () => {
const initialStartData = appendStartFormField(
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
{
type: 'text',
},
);
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '开始节点',
...initialStartData,
},
};
const renamedStartData = updateStartFormField(
startNode.data as Record<string, any>,
'text_field',
{
key: 'topic',
label: '主题',
},
);
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '请围绕 {{start_1.text_field}} 生成内容',
parameters: [
{
id: 'param_1',
name: 'start_1.text_field',
ref: 'start_1.text_field',
refType: 'ref',
autoManaged: true,
formLabel: '开始节点 > 文本字段',
displayName: '开始节点 > 文本字段',
},
],
[FIELD_BINDING_META_KEY]: {
userPrompt: {
autoFilledFrom: 'start_1.text_field',
userModified: false,
},
},
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
];
const nextNodes = renameStartFieldReferencesInNodes(
[
{
...startNode,
data: {
...(startNode.data as Record<string, any>),
...renamedStartData,
},
},
llmNode,
],
edges,
'start_1',
'text_field',
'topic',
);
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
expect(nextLlmNode.data?.userPrompt).toBe(
'请围绕 {{start_1.topic}} 生成内容',
);
expect(nextParameter?.name).toBe('start_1.topic');
expect(nextParameter?.ref).toBe('start_1.topic');
expect(nextParameter?.displayName).toBe('开始节点 > topic');
expect(
(nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]?.userPrompt,
).toMatchObject({
autoFilledFrom: 'start_1.topic',
userModified: false,
});
});
it('refreshes downstream reference labels when the system title changes', () => {
const initial = createInitialWorkflowData();
const customStartData = updateStartFormField(
initial.nodes[0]?.data as Record<string, any>,
'user_input',
{ label: '反洗钱' },
);
const clearedStartData = updateStartFormField(
customStartData,
'user_input',
{ label: '' },
);
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '开始节点',
...clearedStartData,
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '',
parameters: [
{
id: 'param_1',
name: 'start_1.user_input',
ref: 'start_1.user_input',
refType: 'ref',
autoManaged: true,
formLabel: '开始节点 > 反洗钱',
displayName: '开始节点 > 反洗钱',
},
],
},
};
const endNode: Node = {
id: 'end_1',
type: 'endNode',
position: { x: 240, y: 0 },
data: {
title: '结束节点',
parameters: [
{
id: 'param_2',
name: 'start_1.user_input',
ref: 'start_1.user_input',
refType: 'ref',
formLabel: '开始节点 > 反洗钱',
displayName: '开始节点 > 反洗钱',
},
],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
{ id: 'edge_2', source: 'llm_1', target: 'end_1' } as Edge,
];
const nextNodes = renameStartFieldReferencesInNodes(
[startNode, llmNode, endNode],
edges,
'start_1',
'user_input',
'user_input',
);
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
const nextEndParameter = (
nextNodes.find((node) => node.id === 'end_1')?.data?.parameters as any[]
)?.[0];
expect(nextLlmNode.data?.userPrompt).toBe('');
expect(nextParameter?.name).toBe('start_1.user_input');
expect(nextParameter?.ref).toBe('start_1.user_input');
expect(nextParameter?.displayName).toBe('开始节点 > 用户问题');
expect(nextParameter?.formLabel).toBe('开始节点 > 用户问题');
expect(nextEndParameter?.displayName).toBe('开始节点 > 用户问题');
expect(nextEndParameter?.formLabel).toBe('开始节点 > 用户问题');
expect((nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]).toBeUndefined();
});
it('preserves node identities when a title sync has nothing to update', () => {
const initial = createInitialWorkflowData();
const startNode: Node = {
...initial.nodes[0]!,
id: 'start_1',
};
const untouchedNode: Node = {
id: 'code_1',
type: 'codeNode',
position: { x: 120, y: 0 },
data: {
title: '代码节点',
parameters: [
{
id: 'param_1',
name: 'manual_value',
displayName: '手工参数',
},
],
},
};
const nodes = [startNode, untouchedNode];
const nextNodes = renameStartFieldReferencesInNodes(
nodes,
[],
'start_1',
'user_input',
'user_input',
);
expect(nextNodes).toBe(nodes);
expect(nextNodes[0]).toBe(startNode);
expect(nextNodes[1]).toBe(untouchedNode);
});
it('preserves resource content type independently from input type', () => {
for (const contentType of ['image', 'video', 'audio', 'other'] as const) {
const key = `preview_${contentType}`;
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
key,
label: `资源-${contentType}`,
type: 'text',
},
);
const updated = updateStartFormField(appended, key, {
contentType,
});
expect(
updated.startFormSchema?.find((item: any) => item.key === key),
).toMatchObject({
type: 'text',
contentType,
});
expect(
(updated.parameters as any[]).find((item) => item.name === key),
).toMatchObject({
dataType: contentType === 'image' ? 'Object' : 'String',
contentType,
formType: 'input',
});
}
});
it('reconciles a legacy text schema with its image parameter', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const appended = appendStartFormField(startData, {
key: 'legacy_image',
label: '图片',
type: 'text',
});
const imageParameterData = {
...appended,
parameters: (appended.parameters as any[]).map((parameter) =>
parameter.name === 'legacy_image'
? {
...parameter,
contentType: 'image',
dataType: 'Object',
}
: parameter,
),
};
const normalized = normalizeStartNodeData(imageParameterData);
expect(
normalized.startFormSchema?.find(
(field: any) => field.key === 'legacy_image',
),
).toMatchObject({
contentType: 'image',
type: 'text',
});
expect(
(normalized.parameters as any[]).find(
(parameter) => parameter.name === 'legacy_image',
),
).toMatchObject({
contentType: 'image',
dataType: 'Object',
});
const changedToText = updateStartFormField(normalized, 'legacy_image', {
contentType: 'text',
});
expect(
changedToText.startFormSchema?.find(
(field: any) => field.key === 'legacy_image',
),
).toMatchObject({
contentType: 'text',
});
expect(
(changedToText.parameters as any[]).find(
(parameter) => parameter.name === 'legacy_image',
),
).toMatchObject({
contentType: 'text',
dataType: 'String',
});
});
it('uses custom start parameter name for reference display', () => {
const startData = appendStartFormField(
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
{
key: 'topic_name',
label: '下拉字段',
type: 'select',
options: ['A', 'B'],
},
);
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '开始节点',
...startData,
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
];
const parameters = buildEditorReferenceParameters(
'llm_1',
[startNode, llmNode],
edges,
[],
);
const topicParameter = parameters.find(
(item) => item.name === 'start_1.topic_name',
);
expect(topicParameter?.displayName).toBe('开始节点 > topic_name');
expect(topicParameter?.formLabel).toBe('开始节点 > topic_name');
});
it('builds upstream reference candidates from start node', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
];
const parameters = buildEditorReferenceParameters(
'llm_1',
[startNode, llmNode],
edges,
[],
);
expect(parameters.some((item) => item.name === 'start_1.user_input')).toBe(
true,
);
expect(
parameters.find((item) => item.name === 'start_1.user_input')
?.displayName,
).toBe('流程开始 > 用户问题');
});
it('uses output parameter name for reference display', () => {
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 0, y: 0 },
data: {
title: '知识库',
outputDefs: [
{
name: 'documents',
formLabel: '文档列表',
children: [
{
name: 'content',
formLabel: '正文内容',
},
],
},
],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'knowledge_1', target: 'llm_1' } as Edge,
];
const parameters = buildEditorReferenceParameters(
'llm_1',
[knowledgeNode, llmNode],
edges,
[],
);
const documentsParameter = parameters.find(
(item) => item.name === 'knowledge_1.documents',
);
const contentParameter = parameters.find(
(item) => item.name === 'knowledge_1.documents.content',
);
expect(documentsParameter?.displayName).toBe('知识库 > documents');
expect(documentsParameter?.formLabel).toBe('知识库 > documents');
expect(documentsParameter?.pathLabel).toBe('documents');
expect(documentsParameter?.isCollection).toBe(true);
expect(contentParameter?.displayName).toBe('知识库 > documents.[].content');
expect(contentParameter?.formLabel).toBe('知识库 > documents.[].content');
expect(contentParameter?.pathLabel).toBe('documents.[].content');
expect(contentParameter?.itemTypeLabel).toBe('数组项字段');
});
it('exposes typed loop variables only to nodes inside the loop body', () => {
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 0, y: 0 },
data: {
title: '知识库',
outputDefs: [
{
name: 'documents',
dataType: 'Array',
children: [
{ name: 'title', dataType: 'String' },
{ name: 'content', dataType: 'String' },
],
},
],
},
};
const loopNode: Node = {
id: 'loop_1',
type: 'loopNode',
position: { x: 120, y: 0 },
data: {
title: '循环',
loopInputs: {
items: {
name: 'items',
refType: 'ref',
ref: 'knowledge_1.documents',
dataType: 'Array',
},
},
outputDefs: [
{
name: 'res',
dataType: 'Array<String>',
},
],
},
};
const insideNode: Node = {
id: 'llm_inside',
type: 'llmNode',
parentId: 'loop_1',
position: { x: 240, y: 0 },
data: { title: '循环内模型', parameters: [] },
};
const outsideNode: Node = {
id: 'llm_outside',
type: 'llmNode',
position: { x: 240, y: 120 },
data: { title: '循环外模型', parameters: [] },
};
const nodes = [knowledgeNode, loopNode, insideNode, outsideNode];
const insideParameters = buildEditorReferenceParameters(
'llm_inside',
nodes,
[],
[],
);
const outsideParameters = buildEditorReferenceParameters(
'llm_outside',
nodes,
[
{
id: 'loop_to_outside',
source: 'loop_1',
target: 'llm_outside',
},
],
[],
);
expect(insideParameters.map((item) => item.name)).toEqual([
'loop_1.index',
'loop_1.loopItem',
'loop_1.loopItem.title',
'loop_1.loopItem.content',
]);
expect(
insideParameters.find((item) => item.name === 'loop_1.loopItem.content')
?.dataType,
).toBe('String');
expect(outsideParameters.map((item) => item.name)).toEqual(['loop_1.res']);
expect(
outsideParameters.some((item) =>
['loop_1.index', 'loop_1.loopItem'].includes(item.name || ''),
),
).toBe(false);
});
it('uses document node child outputs for reference display', () => {
const documentNode: Node = {
id: 'doc_1',
type: 'document-node',
position: { x: 0, y: 0 },
data: {
title: '文档解析',
outputDefs: [
{
name: 'documents',
formLabel: '文档列表',
children: [
{
name: 'fileName',
formLabel: '文件名',
},
{
name: 'content',
formLabel: '正文内容',
},
],
},
],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'doc_1', target: 'llm_1' } as Edge,
];
const parameters = buildEditorReferenceParameters(
'llm_1',
[documentNode, llmNode],
edges,
[],
);
expect(
parameters.find((item) => item.name === 'doc_1.documents')?.displayName,
).toBe('文档解析 > documents');
expect(
parameters.find((item) => item.name === 'doc_1.documents.fileName')
?.displayName,
).toBe('文档解析 > documents.[].fileName');
expect(
parameters.find((item) => item.name === 'doc_1.documents.content')
?.displayName,
).toBe('文档解析 > documents.[].content');
});
it('applies default binding to llm user prompt after connect', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
userPrompt: '',
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
];
const patch = buildAutoBindingPatch(llmNode, [startNode, llmNode], edges);
expect(patch?.userPrompt).toBe('{{start_1.user_input}}');
expect((patch?.parameters as any[])?.[0]?.name).toBe('start_1.user_input');
expect(
(patch?.[FIELD_BINDING_META_KEY] as any)?.userPrompt?.userModified,
).toBe(false);
});
it('does not auto-bind from legacy start nodes without user_input', () => {
const legacyStartNode: Node = {
id: 'start_legacy',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '开始节点',
parameters: [],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
parameters: [],
userPrompt: '',
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_legacy', target: 'llm_1' } as Edge,
];
expect(
buildAutoBindingPatch(llmNode, [legacyStartNode, llmNode], edges),
).toBeNull();
});
it('clears auto-filled start bindings after disconnect', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '{{start_1.user_input}}',
parameters: [
{
name: 'start_1.user_input',
ref: 'start_1.user_input',
refType: 'ref',
autoManaged: true,
},
],
[FIELD_BINDING_META_KEY]: {
userPrompt: {
autoFilledFrom: 'start_1.user_input',
userModified: false,
},
},
},
};
const patch = buildFieldBindingPatch(llmNode, [startNode, llmNode], []);
expect(patch?.userPrompt).toBe('');
expect(patch?.parameters).toEqual([]);
expect(patch?.[FIELD_BINDING_META_KEY]).toEqual({});
});
it('removes managed param for disconnected manual upstream refs so token becomes invalid', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 80, y: 0 },
data: {
title: '知识库',
outputDefs: [
{
name: 'documents',
dataType: 'String',
},
],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
systemPrompt: '{{knowledge_1.documents}}',
userPrompt: '',
parameters: [
{
name: 'knowledge_1.documents',
ref: 'knowledge_1.documents',
refType: 'ref',
autoManaged: true,
},
],
},
};
const connectedEdges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
];
const connectedParameters = buildEditorReferenceParameters(
'llm_1',
[startNode, knowledgeNode, llmNode],
connectedEdges,
(llmNode.data?.parameters || []) as any[],
);
expect(
connectedParameters.some((item) => item.name === 'knowledge_1.documents'),
).toBe(true);
const patch = buildFieldBindingPatch(
llmNode,
[startNode, knowledgeNode, llmNode],
[],
);
expect((patch?.parameters as any[])?.[0]?.name).toBe(
'knowledge_1.documents',
);
expect((patch?.parameters as any[])?.[0]?.disconnected).toBe(true);
expect((patch?.parameters as any[])?.[0]?.displayName).toBe('documents');
expect(patch).not.toHaveProperty('systemPrompt');
});
it('restores auto-filled user input binding after reconnect through upstream chain', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 80, y: 0 },
data: {
title: '知识库',
keyword: '',
parameters: [],
outputDefs: [
{
name: 'documents',
dataType: 'String',
},
],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
];
const knowledgePatch = buildFieldBindingPatch(
knowledgeNode,
[startNode, knowledgeNode, llmNode],
edges,
);
const nextKnowledgeNode: Node = {
...knowledgeNode,
data: {
...knowledgeNode.data,
...knowledgePatch,
},
};
const llmPatch = buildFieldBindingPatch(
llmNode,
[startNode, nextKnowledgeNode, llmNode],
edges,
);
expect(knowledgePatch?.keyword).toBe('{{start_1.user_input}}');
expect(llmPatch?.userPrompt).toBe('{{start_1.user_input}}');
});
it('applies reconnect patches sequentially so downstream nodes can restore in the same batch', () => {
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '流程开始',
parameters: ensureStartNodeParameters(),
},
};
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 80, y: 0 },
data: {
title: '知识库',
keyword: '',
parameters: [],
outputDefs: [
{
name: 'documents',
dataType: 'String',
},
],
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '',
parameters: [],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
];
const patches = buildSequentialFieldBindingPatches(
['knowledge_1', 'llm_1'],
[startNode, knowledgeNode, llmNode],
edges,
);
expect(patches).toHaveLength(2);
expect(patches[0]).toMatchObject({
nodeId: 'knowledge_1',
patch: {
keyword: '{{start_1.user_input}}',
},
});
expect(patches[1]).toMatchObject({
nodeId: 'llm_1',
patch: {
userPrompt: '{{start_1.user_input}}',
},
});
});
it('extracts only used parameters for llm single run', () => {
const parameters = ensureStartNodeParameters().map((item) => ({
...item,
name: 'start_1.user_input',
ref: 'start_1.user_input',
formLabel: '流程开始 > 用户问题',
displayName: '流程开始 > 用户问题',
systemReserved: false,
autoManaged: true,
}));
const result = buildSingleRunParameters({
type: 'llmNode',
data: {
userPrompt: '请回答 {{start_1.user_input}}',
systemPrompt: '系统',
parameters,
},
});
expect(result).toHaveLength(1);
expect(result[0]?.formLabel).toBe('流程开始 > 用户问题');
expect(result[0]?.required).toBe(true);
});
it('builds field-mode single run model for llm node', () => {
const parameters = ensureStartNodeParameters().map((item) => ({
...item,
name: 'start_1.user_input',
ref: 'start_1.user_input',
formLabel: '流程开始 > 用户问题',
displayName: '流程开始 > 用户问题',
systemReserved: false,
autoManaged: true,
}));
const result = buildSingleRunModel({
type: 'llmNode',
data: {
userPrompt: '请回答 {{start_1.user_input}}',
systemPrompt: '你是助手',
parameters,
},
});
expect(result.mode).toBe('fields');
expect(result.fields.map((item) => item.key)).toEqual([
'systemPrompt',
'userPrompt',
]);
expect(result.parameters).toHaveLength(1);
expect(result.parameters[0]?.formLabel).toBe('流程开始 > 用户问题');
});
it('keeps legacy start node parameters unchanged during single run build', () => {
const legacyParameters = [
{
id: 'legacy_1',
name: 'legacy_input',
refType: 'input',
dataType: 'String',
},
];
const result = buildSingleRunParameters({
type: 'startNode',
data: {
parameters: legacyParameters,
},
});
expect(result).toEqual(legacyParameters);
});
it('normalizes start node schema from parameters', () => {
const normalized = normalizeStartNodeData({
parameters: [
{
id: 'system_1',
name: 'user_input',
refType: 'input',
required: true,
formType: 'input',
formLabel: '问题',
formPlaceholder: '请输入问题',
},
{
id: 'file_1',
name: 'attachments',
refType: 'input',
dataType: 'File',
contentType: 'file',
formLabel: '附件',
required: false,
},
],
});
expect(normalized.startFormSchema).toHaveLength(2);
expect(normalized.startFormSchema[0]).toMatchObject({
key: 'user_input',
type: 'text',
systemReserved: true,
required: true,
});
expect(normalized.startFormSchema[1]).toMatchObject({
key: 'attachments',
type: 'file',
required: false,
});
expect(normalized.parameters[1]).toMatchObject({
name: 'attachments',
contentType: 'file',
dataType: 'File',
});
});
it('forces invalid user_input schema back to required text input', () => {
const normalized = normalizeStartNodeData({
startFormSchema: [
{
key: 'user_input',
label: '主问题',
type: 'radio',
required: false,
options: ['A', 'B'],
},
],
startFormMeta: {
title: '',
description: '',
submitText: '',
},
});
expect(normalized.startFormSchema[0]).toMatchObject({
key: 'user_input',
type: 'textarea',
required: true,
systemReserved: true,
});
expect(normalized.startFormMeta).toMatchObject({
title: '',
description: '',
submitText: '开始',
});
expect(normalized.parameters[0]).toMatchObject({
name: 'user_input',
formType: 'textarea',
required: true,
contentType: 'text',
});
});
it('normalizes only start nodes that already contain fixed user_input', () => {
const normalizedWorkflow = normalizeWorkflowStartNodes({
nodes: [
{
id: 'start_new',
type: 'startNode',
data: {
parameters: [
{
name: 'user_input',
refType: 'input',
required: false,
},
],
},
},
{
id: 'start_legacy',
type: 'startNode',
data: {
parameters: [],
},
},
],
edges: [],
});
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
true,
);
expect(
(normalizedWorkflow.nodes[0]?.data as Record<string, any> | undefined)
?.startFormSchema?.[0]?.key,
).toBe('user_input');
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
});
});