feat: 完成工作流开始节点开场表单
- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析 - 统一 Admin/UserCenter 开场表单渲染与文件集合输入 - 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import {
|
||||
appendStartFormField,
|
||||
buildAutoBindingPatch,
|
||||
buildSequentialFieldBindingPatches,
|
||||
buildFieldBindingPatch,
|
||||
@@ -12,7 +13,11 @@ import {
|
||||
createInitialWorkflowData,
|
||||
ensureStartNodeParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
normalizeStartNodeData,
|
||||
normalizeWorkflowStartNodes,
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
updateStartFormField,
|
||||
} from './workflowNodeFields';
|
||||
|
||||
describe('workflow node fields', () => {
|
||||
@@ -28,6 +33,246 @@ describe('workflow node fields', () => {
|
||||
expect(parameters[0]?.name).toBe('user_input');
|
||||
expect(parameters[0]?.systemReserved).toBe(true);
|
||||
expect(parameters[0]?.required).toBe(true);
|
||||
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
|
||||
title: '开始问答',
|
||||
submitText: '开始',
|
||||
});
|
||||
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'textarea',
|
||||
systemReserved: true,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
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',
|
||||
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('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('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', () => {
|
||||
@@ -69,6 +314,120 @@ describe('workflow node fields', () => {
|
||||
).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(contentParameter?.displayName).toBe('知识库 > documents.content');
|
||||
expect(contentParameter?.formLabel).toBe('知识库 > documents.content');
|
||||
});
|
||||
|
||||
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: '正文内容',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contents',
|
||||
formLabel: '逐文件内容',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
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');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.contents')?.displayName)
|
||||
.toBe('文档解析 > contents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.count')?.displayName)
|
||||
.toBe('文档解析 > count');
|
||||
});
|
||||
|
||||
it('applies default binding to llm user prompt after connect', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
@@ -436,6 +795,86 @@ describe('workflow node fields', () => {
|
||||
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: [
|
||||
@@ -466,6 +905,9 @@ describe('workflow node fields', () => {
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||
true,
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.startFormSchema?.[0]?.key).toBe(
|
||||
'user_input',
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user