feat: 完成工作流开始节点开场表单

- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析

- 统一 Admin/UserCenter 开场表单渲染与文件集合输入

- 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
2026-04-19 13:57:57 +08:00
parent 8546d927bc
commit 9feb889637
26 changed files with 3391 additions and 172 deletions

View File

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