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

View File

@@ -31,6 +31,66 @@ export type SingleRunModel =
fields: SingleRunFieldDescriptor[];
};
export type StartFormFieldType =
| 'text'
| 'textarea'
| 'radio'
| 'checkbox'
| 'select'
| 'file';
export type StartFormMeta = {
title: string;
description: string;
submitText: string;
};
export type StartFormFieldSchema = {
id?: string;
key: string;
label: string;
type: StartFormFieldType;
required: boolean;
placeholder?: string;
description?: string;
defaultValue?: string | string[];
options: string[];
systemReserved?: boolean;
};
const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
'text',
'textarea',
'radio',
'checkbox',
'select',
'file',
]);
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
'radio',
'checkbox',
'select',
]);
const DEFAULT_START_FORM_TITLE = '开始问答';
const DEFAULT_START_FORM_DESCRIPTION = '请先补充必要信息,再开始执行工作流。';
const DEFAULT_START_FORM_SUBMIT_TEXT = '开始';
const START_FORM_DEFAULT_FIELD_KEY_BY_TYPE: Record<StartFormFieldType, string> = {
text: 'text_field',
textarea: 'textarea_field',
radio: 'radio_field',
checkbox: 'checkbox_field',
select: 'select_field',
file: 'file_field',
};
const START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE: Record<StartFormFieldType, string> = {
text: '文本字段',
textarea: '长文本字段',
radio: '单选字段',
checkbox: '多选字段',
select: '下拉字段',
file: '文件字段',
};
type FieldBindingMeta = Record<
string,
{
@@ -43,6 +103,25 @@ function asString(value: unknown) {
return value == null ? '' : String(value);
}
function trimString(value: unknown) {
return asString(value).trim();
}
function ensureStringArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => asString(item).trim()).filter(Boolean);
}
function isSupportedStartFormFieldType(value: unknown): value is StartFormFieldType {
return START_FORM_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
}
function isOptionFieldType(value: unknown): value is StartFormFieldType {
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
}
function cloneParameter(parameter: Parameter): Parameter {
return {
...parameter,
@@ -74,6 +153,13 @@ function getParameterLabel(parameter?: Parameter | null) {
);
}
function getReferenceParameterLabel(parameter?: Parameter | null) {
if (isSystemStartParameter(parameter)) {
return getParameterLabel(parameter);
}
return asString(parameter?.name).trim() || getParameterLabel(parameter);
}
function buildDisconnectedDisplayName(parameter: Parameter) {
const displayName =
asString(parameter.displayName).trim() ||
@@ -127,8 +213,8 @@ function flattenOutputDefs(
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
const label = parentLabel
? `${parentLabel}.${getParameterLabel(parameter)}`
: getParameterLabel(parameter);
? `${parentLabel}.${getReferenceParameterLabel(parameter)}`
: getReferenceParameterLabel(parameter);
const fullRef = `${node.id}.${path}`;
const baseCandidate: Parameter = ensureParameterId({
name: fullRef,
@@ -167,8 +253,8 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
name: `${node.id}.${asString(parameter.name).trim()}`,
ref: `${node.id}.${asString(parameter.name).trim()}`,
refType: 'ref',
displayName: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
formLabel: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
displayName: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
formLabel: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true,
@@ -233,17 +319,115 @@ function toManagedRefParameter(refPath: string, candidate?: Parameter): Paramete
});
}
export function createSystemStartParameter(): Parameter {
function normalizeSystemStartFormType(value: unknown): 'input' | 'textarea' {
return trimString(value) === 'input' ? 'input' : 'textarea';
}
function normalizeStartFormFieldUiType(
value: unknown,
fallback: StartFormFieldType = 'text',
): StartFormFieldType {
const normalized = trimString(value);
if (normalized === 'input') {
return 'text';
}
if (isSupportedStartFormFieldType(normalized)) {
return normalized;
}
return fallback;
}
function getDefaultStartFormFieldKeyBase(type: StartFormFieldType) {
return START_FORM_DEFAULT_FIELD_KEY_BY_TYPE[type];
}
function getDefaultStartFormFieldLabel(type: StartFormFieldType) {
return START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE[type];
}
function buildUniqueStartFormFieldKey(
type: StartFormFieldType,
existingKeys: string[],
) {
const normalizedExistingKeys = new Set(
existingKeys.map((item) => trimString(item)).filter(Boolean),
);
const baseKey = getDefaultStartFormFieldKeyBase(type);
if (!normalizedExistingKeys.has(baseKey)) {
return baseKey;
}
let index = 2;
while (normalizedExistingKeys.has(`${baseKey}_${index}`)) {
index += 1;
}
return `${baseKey}_${index}`;
}
function isAutoGeneratedStartFormFieldKey(key: unknown) {
const normalizedKey = trimString(key);
if (!normalizedKey) {
return false;
}
if (/^field_[A-Za-z0-9]+$/.test(normalizedKey)) {
return true;
}
return Object.values(START_FORM_DEFAULT_FIELD_KEY_BY_TYPE).some((baseKey) => {
return normalizedKey === baseKey || new RegExp(`^${baseKey}_[0-9]+$`).test(normalizedKey);
});
}
function isDefaultStartFormFieldLabel(label: unknown) {
const normalizedLabel = trimString(label);
if (!normalizedLabel) {
return false;
}
if (normalizedLabel === '新字段') {
return true;
}
return Object.values(START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE).includes(normalizedLabel);
}
function ensureStartFormFieldId(
fieldId: unknown,
existingParameter?: Parameter | null,
) {
return trimString(fieldId) || trimString(existingParameter?.id) || genShortId();
}
function createDefaultStartFormMeta(): StartFormMeta {
return {
title: DEFAULT_START_FORM_TITLE,
description: DEFAULT_START_FORM_DESCRIPTION,
submitText: DEFAULT_START_FORM_SUBMIT_TEXT,
};
}
export function normalizeStartFormMeta(meta?: Partial<StartFormMeta> | null): StartFormMeta {
const fallback = createDefaultStartFormMeta();
return {
title: trimString(meta?.title) || fallback.title,
description: trimString(meta?.description) || fallback.description,
submitText: trimString(meta?.submitText) || fallback.submitText,
};
}
function normalizeSystemStartParameter(parameter?: Parameter | null): Parameter {
const fallbackLabel = SYSTEM_START_PARAM_LABEL;
const formLabel = trimString(parameter?.formLabel) || fallbackLabel;
const formType = normalizeSystemStartFormType(parameter?.formType);
return ensureParameterId({
...cloneParameter(parameter || {}),
name: SYSTEM_START_PARAM_NAME,
dataType: 'String',
refType: 'input',
required: true,
contentType: 'text',
formType: 'input',
formLabel: SYSTEM_START_PARAM_LABEL,
formPlaceholder: '请输入用户问题',
displayName: `流程开始 > ${SYSTEM_START_PARAM_LABEL}`,
formType,
formLabel,
formDescription: asString(parameter?.formDescription),
formPlaceholder: trimString(parameter?.formPlaceholder) || '请输入用户问题',
defaultValue: asString(parameter?.defaultValue),
displayName: `流程开始 > ${formLabel}`,
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true,
@@ -252,6 +436,399 @@ export function createSystemStartParameter(): Parameter {
});
}
function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartFormFieldType {
if (
trimString(parameter?.contentType) === 'file' ||
trimString(parameter?.dataType).toLowerCase() === 'file'
) {
return 'file';
}
const formType = trimString(parameter?.formType);
if (formType === 'textarea') {
return 'textarea';
}
if (formType === 'select') {
return 'select';
}
if (formType === 'radio') {
return 'radio';
}
if (formType === 'checkbox') {
return 'checkbox';
}
return 'text';
}
function fieldTypeToDataType(type: StartFormFieldType) {
if (type === 'checkbox') {
return 'Array';
}
if (type === 'file') {
return 'File';
}
return 'String';
}
function fieldTypeToContentType(type: StartFormFieldType) {
return type === 'file' ? 'file' : 'text';
}
function fieldTypeToFormType(type: StartFormFieldType) {
if (type === 'text') {
return 'input';
}
if (type === 'file') {
return 'input';
}
return type;
}
function normalizeStartFormField(
field: Partial<StartFormFieldSchema> | null | undefined,
existingParameter?: Parameter,
): StartFormFieldSchema | null {
const key = trimString(field?.key) || trimString(existingParameter?.name);
if (!key) {
return null;
}
const fallbackType = parameterTypeToStartFormFieldType(existingParameter);
const requestedType = isSupportedStartFormFieldType(field?.type)
? field?.type
: fallbackType;
const isSystemField =
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
const type = isSystemField
? requestedType === 'text'
? 'text'
: 'textarea'
: requestedType;
const options = isOptionFieldType(type)
? ensureStringArray(field?.options || existingParameter?.enums)
: [];
const defaultValue = Array.isArray(field?.defaultValue)
? ensureStringArray(field?.defaultValue)
: asString(field?.defaultValue ?? existingParameter?.defaultValue);
return {
id: ensureStartFormFieldId(field?.id, existingParameter),
key,
label:
trimString(field?.label) ||
trimString(existingParameter?.formLabel) ||
trimString(existingParameter?.displayName) ||
key,
type,
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
placeholder:
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
description:
trimString(field?.description) || trimString(existingParameter?.formDescription),
defaultValue,
options,
systemReserved: isSystemField,
};
}
function parameterToStartFormField(parameter?: Parameter | null) {
return normalizeStartFormField(
{
id: trimString(parameter?.id),
key: trimString(parameter?.name),
label: trimString(parameter?.formLabel),
type: parameterTypeToStartFormFieldType(parameter),
required: Boolean(parameter?.required),
placeholder: trimString(parameter?.formPlaceholder),
description: trimString(parameter?.formDescription),
defaultValue: parameter?.defaultValue,
options: parameter?.enums || [],
systemReserved: isSystemStartParameter(parameter),
},
parameter || undefined,
);
}
function startFormFieldToParameter(
field: StartFormFieldSchema,
existingParameter?: Parameter,
): Parameter {
if (field.systemReserved || field.key === SYSTEM_START_PARAM_NAME) {
return normalizeSystemStartParameter({
...existingParameter,
id: trimString(existingParameter?.id) || trimString(field.id),
formLabel: field.label,
formDescription: field.description,
formPlaceholder: field.placeholder,
formType: field.type === 'text' ? 'input' : 'textarea',
defaultValue: Array.isArray(field.defaultValue)
? field.defaultValue.join('\n')
: asString(field.defaultValue),
});
}
const formLabel = trimString(field.label) || field.key;
return ensureParameterId({
...cloneParameter(existingParameter || {}),
id: trimString(existingParameter?.id) || trimString(field.id),
name: field.key,
dataType: fieldTypeToDataType(field.type),
refType: 'input',
required: Boolean(field.required),
contentType: fieldTypeToContentType(field.type),
formType: fieldTypeToFormType(field.type),
formLabel,
formDescription: asString(field.description),
formPlaceholder: asString(field.placeholder),
defaultValue: Array.isArray(field.defaultValue)
? field.defaultValue.join('\n')
: asString(field.defaultValue),
enums: isOptionFieldType(field.type) ? ensureStringArray(field.options) : [],
displayName: `流程开始 > ${formLabel}`,
systemReserved: false,
});
}
export function normalizeStartFormSchema(
schema?: Partial<StartFormFieldSchema>[] | null,
parameters?: Parameter[] | null,
) {
const parameterCandidates = Array.isArray(parameters)
? parameters.map(ensureParameterId)
: [];
const parameterMap = new Map(
parameterCandidates
.map((parameter) => [trimString(parameter.name), parameter] as const)
.filter(([key]) => key.length > 0),
);
const parameterIdMap = new Map(
parameterCandidates
.map((parameter) => [trimString(parameter.id), parameter] as const)
.filter(([key]) => key.length > 0),
);
const source = Array.isArray(schema) && schema.length > 0
? schema
: parameterCandidates
.map((parameter) => parameterToStartFormField(parameter))
.filter(Boolean);
const result: StartFormFieldSchema[] = [];
const seen = new Set<string>();
for (const item of source) {
const fieldId = trimString((item as Partial<StartFormFieldSchema>)?.id);
const key =
trimString((item as Partial<StartFormFieldSchema>)?.key) ||
trimString((item as any)?.name);
if (!key || seen.has(key)) {
continue;
}
const normalized = normalizeStartFormField(
item as Partial<StartFormFieldSchema>,
(fieldId ? parameterIdMap.get(fieldId) : undefined) || parameterMap.get(key),
);
if (!normalized) {
continue;
}
seen.add(normalized.key);
if (normalized.key === SYSTEM_START_PARAM_NAME) {
result.unshift(normalized);
} else {
result.push(normalized);
}
}
if (!seen.has(SYSTEM_START_PARAM_NAME)) {
const systemParameter = parameterCandidates.find((parameter) =>
isSystemStartParameter(parameter),
);
result.unshift(
normalizeStartFormField(
{
id: trimString(systemParameter?.id),
key: SYSTEM_START_PARAM_NAME,
label: trimString(systemParameter?.formLabel) || SYSTEM_START_PARAM_LABEL,
type:
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
? 'text'
: 'textarea',
required: true,
placeholder: trimString(systemParameter?.formPlaceholder),
description: trimString(systemParameter?.formDescription),
defaultValue: asString(systemParameter?.defaultValue),
options: [],
systemReserved: true,
},
systemParameter,
)!,
);
}
return result;
}
export function createCustomStartFormField(
field?: Partial<StartFormFieldSchema> | null,
existingKeys: string[] = [],
): StartFormFieldSchema {
const type = normalizeStartFormFieldUiType(field?.type, 'text');
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
return {
id: ensureStartFormFieldId(field?.id),
key,
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
type,
required: Boolean(field?.required),
placeholder:
trimString(field?.placeholder) ||
(type === 'file' ? '请选择文件' : '请输入内容'),
description: trimString(field?.description),
defaultValue: Array.isArray(field?.defaultValue)
? ensureStringArray(field?.defaultValue)
: asString(field?.defaultValue),
options: isOptionFieldType(type) ? ensureStringArray(field?.options) : [],
systemReserved: false,
};
}
export function appendStartFormField(
data?: Record<string, any> | null,
field?: Partial<StartFormFieldSchema> | null,
) {
const currentData = (data || {}) as Record<string, any>;
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
const nextField = createCustomStartFormField(
field,
schema.map((item) => item.key),
);
return normalizeStartNodeData({
...currentData,
startFormSchema: [...schema, nextField],
});
}
export function updateStartFormField(
data: Record<string, any> | null | undefined,
currentKey: string,
patch: Partial<StartFormFieldSchema>,
) {
const currentData = (data || {}) as Record<string, any>;
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
const nextSchema = schema.map((field) => {
if (field.key !== currentKey) {
return field;
}
const nextType = normalizeStartFormFieldUiType(patch.type, field.type);
const shouldAutoRenameKey =
!trimString(patch.key) &&
patch.type != null &&
isAutoGeneratedStartFormFieldKey(field.key);
const shouldAutoRenameLabel =
!trimString(patch.label) &&
patch.type != null &&
isDefaultStartFormFieldLabel(field.label);
return {
...field,
...patch,
key: trimString(patch.key) || (
shouldAutoRenameKey
? buildUniqueStartFormFieldKey(
nextType,
schema
.filter((item) => item.key !== field.key)
.map((item) => item.key),
)
: field.key
),
label: trimString(patch.label) || (
shouldAutoRenameLabel ? getDefaultStartFormFieldLabel(nextType) : field.label
),
type: nextType,
};
});
return normalizeStartNodeData({
...currentData,
startFormSchema: nextSchema,
});
}
export function removeStartFormField(
data?: Record<string, any> | null,
key?: string | null,
) {
const currentKey = trimString(key);
if (!currentKey || currentKey === SYSTEM_START_PARAM_NAME) {
return normalizeStartNodeData(data || {});
}
const currentData = (data || {}) as Record<string, any>;
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
return normalizeStartNodeData({
...currentData,
startFormSchema: schema.filter((field) => field.key !== currentKey),
});
}
export function normalizeStartNodeData(
data?: Record<string, any> | null,
options?: {
allowLegacyParametersOnly?: boolean;
},
) {
const currentData = (data || {}) as Record<string, any>;
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];
const hasSchema = Array.isArray(currentData.startFormSchema);
const hasSystemParameter = hasSystemStartParameter(currentParameters);
if (!hasSchema && !hasSystemParameter && options?.allowLegacyParametersOnly) {
return {
parameters: currentParameters.map(ensureParameterId),
startFormMeta: currentData.startFormMeta,
startFormSchema: currentData.startFormSchema,
};
}
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
const parameterMap = new Map(
currentParameters
.map((parameter) => [trimString(parameter.name), parameter] as const)
.filter(([key]) => key.length > 0),
);
const parameterIdMap = new Map(
currentParameters
.map((parameter) => [trimString(parameter.id), parameter] as const)
.filter(([key]) => key.length > 0),
);
const parameters = schema.map((field) =>
startFormFieldToParameter(
field,
(trimString(field.id) ? parameterIdMap.get(trimString(field.id)) : undefined) ||
parameterMap.get(field.key),
),
);
return {
parameters,
startFormMeta: normalizeStartFormMeta(currentData.startFormMeta),
startFormSchema: schema,
};
}
export function createSystemStartParameter(): Parameter {
return normalizeSystemStartParameter();
}
export function isSystemStartParameter(parameter?: Parameter | null) {
if (!parameter) {
return false;
@@ -273,18 +850,12 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
const source = Array.isArray(parameters)
? parameters.map(cloneParameter)
: [];
const fixed = createSystemStartParameter();
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
if (index >= 0) {
const existing = source[index]!;
source[index] = ensureParameterId({
...existing,
...fixed,
id: existing.id || fixed.id,
});
source[index] = normalizeSystemStartParameter(source[index]!);
} else {
source.unshift(fixed);
source.unshift(createSystemStartParameter());
}
const customParameters = source
@@ -295,6 +866,14 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
}
export function createInitialWorkflowData() {
const startData = normalizeStartNodeData(
{
parameters: ensureStartNodeParameters(),
},
{
allowLegacyParametersOnly: false,
},
);
return {
nodes: [
{
@@ -303,7 +882,7 @@ export function createInitialWorkflowData() {
position: { x: 80, y: 180 },
data: {
title: '开始节点',
parameters: ensureStartNodeParameters(),
...startData,
},
} satisfies Node,
],
@@ -326,22 +905,29 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data:
if (node?.type !== START_NODE_TYPE) {
return node;
}
const currentParameters = Array.isArray(node.data?.parameters)
? (node.data.parameters as Parameter[])
const currentData = (node.data || {}) as Record<string, any>;
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];
if (!hasSystemStartParameter(currentParameters)) {
const shouldNormalize =
Array.isArray(currentData.startFormSchema) ||
hasSystemStartParameter(currentParameters);
if (!shouldNormalize) {
return node;
}
const normalizedParameters = ensureStartNodeParameters(currentParameters);
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
const normalizedData = normalizeStartNodeData(currentData);
const nextData = {
...currentData,
...normalizedData,
};
if (JSON.stringify(currentData) === JSON.stringify(nextData)) {
return node;
}
changed = true;
return {
...node,
data: {
...(node.data || {}),
parameters: normalizedParameters,
...nextData,
},
};
});
@@ -563,6 +1149,148 @@ function getSupportedFieldKeys(nodeType: string | undefined) {
return [];
}
function replaceStartFieldReferenceValue(
value: unknown,
oldRefPath: string,
newRefPath: string,
): unknown {
if (typeof value === 'string') {
const oldToken = toToken(oldRefPath);
const newToken = toToken(newRefPath);
if (value === oldRefPath) {
return newRefPath;
}
if (!value.includes(oldToken)) {
return value;
}
return value.split(oldToken).join(newToken);
}
if (Array.isArray(value)) {
let changed = false;
const nextValue = value.map((item) => {
const nextItem = replaceStartFieldReferenceValue(item, oldRefPath, newRefPath);
if (nextItem !== item) {
changed = true;
}
return nextItem;
});
return changed ? nextValue : value;
}
if (value && typeof value === 'object') {
let changed = false;
const nextValue = Object.fromEntries(
Object.entries(value).map(([key, itemValue]) => {
const nextItemValue = replaceStartFieldReferenceValue(
itemValue,
oldRefPath,
newRefPath,
);
if (nextItemValue !== itemValue) {
changed = true;
}
return [key, nextItemValue];
}),
);
return changed ? nextValue : value;
}
return value;
}
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
const nodeIds = new Set<string>();
const visit = (nodeId: string) => {
if (!nodeId || nodeIds.has(nodeId)) {
return;
}
nodeIds.add(nodeId);
edges
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
.forEach((edge) => {
if (edge.target) {
visit(edge.target);
}
});
};
visit(rootNodeId);
nodeIds.delete(rootNodeId);
return Array.from(nodeIds);
}
export function renameStartFieldReferencesInNodes(
nodes: Node[],
edges: Edge[],
startNodeId: string,
currentKey: string,
nextKey: string,
) {
const normalizedStartNodeId = trimString(startNodeId);
const normalizedCurrentKey = trimString(currentKey);
const normalizedNextKey = trimString(nextKey);
if (
!normalizedStartNodeId ||
!normalizedCurrentKey ||
!normalizedNextKey ||
normalizedCurrentKey === normalizedNextKey
) {
return nodes;
}
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
const nextNodes = nodes.map((node) => {
if (node.id === normalizedStartNodeId) {
return node;
}
const nextData = replaceStartFieldReferenceValue(
node.data,
oldRefPath,
newRefPath,
) as Record<string, any> | undefined;
if (nextData === node.data) {
return node;
}
return {
...node,
data: nextData,
};
});
const affectedNodeIds = collectDownstreamNodeIds(normalizedStartNodeId, edges);
if (affectedNodeIds.length === 0) {
return nextNodes;
}
const patches = buildSequentialFieldBindingPatches(
affectedNodeIds,
nextNodes,
edges,
);
if (patches.length === 0) {
return nextNodes;
}
const patchMap = new Map(patches.map((item) => [item.nodeId, item.patch] as const));
return nextNodes.map((node) => {
const patch = patchMap.get(node.id);
if (!patch) {
return node;
}
return {
...node,
data: {
...((node.data || {}) as Record<string, any>),
...patch,
},
};
});
}
function getSingleRunFieldDescriptors(
node: Pick<Node, 'type' | 'data'>,
): SingleRunFieldDescriptor[] {
@@ -778,12 +1506,15 @@ export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | nul
}
if (node.type === START_NODE_TYPE) {
const parameters = Array.isArray(node.data.parameters)
? ((node.data.parameters as Parameter[]) || [])
const normalizedData = normalizeStartNodeData(
(node.data || {}) as Record<string, any>,
{
allowLegacyParametersOnly: true,
},
);
return Array.isArray(normalizedData.parameters)
? (normalizedData.parameters as Parameter[])
: [];
return hasSystemStartParameter(parameters)
? ensureStartNodeParameters(parameters)
: parameters;
}
const parameters = Array.isArray(node.data.parameters)