feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数 - LLM 与知识库节点切换为字段值加上游引用配置 - 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import {
|
||||
buildAutoBindingPatch,
|
||||
buildSequentialFieldBindingPatches,
|
||||
buildFieldBindingPatch,
|
||||
buildEditorReferenceParameters,
|
||||
buildSingleRunModel,
|
||||
buildSingleRunParameters,
|
||||
createInitialWorkflowData,
|
||||
ensureStartNodeParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
normalizeWorkflowStartNodes,
|
||||
} 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);
|
||||
});
|
||||
|
||||
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('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 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[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,858 @@
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import type { Parameter } from '../types';
|
||||
import { getTokenRanges } from '../components/utils/paramToken';
|
||||
import { genShortId } from '../components/utils/IdGen';
|
||||
|
||||
export const START_NODE_TYPE = 'startNode';
|
||||
export const LLM_NODE_TYPE = 'llmNode';
|
||||
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
|
||||
export const SYSTEM_START_PARAM_NAME = 'user_input';
|
||||
export const SYSTEM_START_PARAM_LABEL = '用户问题';
|
||||
export const FIELD_BINDING_META_KEY = 'fieldBindingMeta';
|
||||
|
||||
export type SingleRunFieldDescriptor = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
};
|
||||
|
||||
export type SingleRunModel =
|
||||
| {
|
||||
mode: 'parameters';
|
||||
parameters: Parameter[];
|
||||
fields: [];
|
||||
}
|
||||
| {
|
||||
mode: 'fields';
|
||||
parameters: Parameter[];
|
||||
fields: SingleRunFieldDescriptor[];
|
||||
};
|
||||
|
||||
type FieldBindingMeta = Record<
|
||||
string,
|
||||
{
|
||||
autoFilledFrom?: string;
|
||||
userModified?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function asString(value: unknown) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function cloneParameter(parameter: Parameter): Parameter {
|
||||
return {
|
||||
...parameter,
|
||||
children: parameter.children?.map(cloneParameter),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureParameterId(parameter: Parameter): Parameter {
|
||||
const cloned = cloneParameter(parameter);
|
||||
if (!cloned.id) {
|
||||
cloned.id = genShortId();
|
||||
}
|
||||
if (cloned.children?.length) {
|
||||
cloned.children = cloned.children.map(ensureParameterId);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function getNodeTitle(node?: Node | null) {
|
||||
return asString(node?.data?.title).trim() || '节点';
|
||||
}
|
||||
|
||||
function getParameterLabel(parameter?: Parameter | null) {
|
||||
return (
|
||||
asString(parameter?.formLabel).trim() ||
|
||||
asString(parameter?.displayName).trim() ||
|
||||
asString(parameter?.name).trim() ||
|
||||
'参数'
|
||||
);
|
||||
}
|
||||
|
||||
function buildDisconnectedDisplayName(parameter: Parameter) {
|
||||
const displayName =
|
||||
asString(parameter.displayName).trim() ||
|
||||
asString(parameter.formLabel).trim();
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
const name = asString(parameter.name).trim();
|
||||
if (!name) {
|
||||
return '参数';
|
||||
}
|
||||
const segments = name.split('.');
|
||||
return segments[segments.length - 1] || name;
|
||||
}
|
||||
|
||||
function flattenNodeRefs(
|
||||
currentNodeId: string,
|
||||
edges: Edge[],
|
||||
refNodeIds: string[],
|
||||
visited: Set<string>,
|
||||
) {
|
||||
if (visited.has(currentNodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(currentNodeId);
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.target === currentNodeId && edge.source) {
|
||||
refNodeIds.push(edge.source);
|
||||
flattenNodeRefs(edge.source, edges, refNodeIds, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenOutputDefs(
|
||||
node: Node,
|
||||
parameters: Parameter[],
|
||||
parentPath = '',
|
||||
parentLabel = '',
|
||||
): Parameter[] {
|
||||
if (!parameters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parameters.flatMap((parameter) => {
|
||||
const rawName = asString(parameter.name).trim();
|
||||
if (!rawName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const label = parentLabel
|
||||
? `${parentLabel}.${getParameterLabel(parameter)}`
|
||||
: getParameterLabel(parameter);
|
||||
const fullRef = `${node.id}.${path}`;
|
||||
const baseCandidate: Parameter = ensureParameterId({
|
||||
name: fullRef,
|
||||
ref: fullRef,
|
||||
refType: 'ref',
|
||||
dataType: parameter.dataType || 'String',
|
||||
displayName: `${getNodeTitle(node)} > ${label}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${label}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
});
|
||||
|
||||
const children = flattenOutputDefs(
|
||||
node,
|
||||
parameter.children || [],
|
||||
path,
|
||||
label,
|
||||
);
|
||||
|
||||
return [baseCandidate, ...children];
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
: [];
|
||||
return parameters
|
||||
.filter((parameter) => asString(parameter.name).trim())
|
||||
.map((parameter) =>
|
||||
ensureParameterId({
|
||||
...cloneParameter(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)}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const outputDefs = Array.isArray(node.data?.outputDefs)
|
||||
? (node.data.outputDefs as Parameter[])
|
||||
: [];
|
||||
return flattenOutputDefs(node, outputDefs);
|
||||
}
|
||||
|
||||
function uniqueParameters(parameters: Parameter[]) {
|
||||
const result: Parameter[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const parameter of parameters) {
|
||||
const key = asString(parameter.name).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const existingIndex = indexMap.get(key);
|
||||
if (existingIndex == null) {
|
||||
indexMap.set(key, result.length);
|
||||
result.push(ensureParameterId(parameter));
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingParameter = result[existingIndex]!;
|
||||
result[existingIndex] = ensureParameterId({
|
||||
...existingParameter,
|
||||
...parameter,
|
||||
children:
|
||||
parameter.children?.length || existingParameter.children?.length
|
||||
? parameter.children || existingParameter.children
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findParameterByName(parameters: Parameter[], name: string) {
|
||||
return parameters.find((parameter) => asString(parameter.name).trim() === name);
|
||||
}
|
||||
|
||||
function toManagedRefParameter(refPath: string, candidate?: Parameter): Parameter {
|
||||
return ensureParameterId({
|
||||
name: refPath,
|
||||
ref: candidate?.ref || refPath,
|
||||
refType: 'ref',
|
||||
dataType: candidate?.dataType || 'String',
|
||||
displayName: candidate?.displayName || candidate?.formLabel || refPath,
|
||||
formLabel: candidate?.formLabel || candidate?.displayName || refPath,
|
||||
description: candidate?.description,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
return ensureParameterId({
|
||||
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}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
requiredDisabled: true,
|
||||
systemReserved: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function isSystemStartParameter(parameter?: Parameter | null) {
|
||||
if (!parameter) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
parameter.systemReserved === true ||
|
||||
(asString(parameter.name).trim() === SYSTEM_START_PARAM_NAME &&
|
||||
asString(parameter.refType).trim() === 'input')
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSystemStartParameter(parameters?: Parameter[] | null) {
|
||||
return Array.isArray(parameters)
|
||||
? parameters.some((parameter) => isSystemStartParameter(parameter))
|
||||
: false;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
source.unshift(fixed);
|
||||
}
|
||||
|
||||
const customParameters = source
|
||||
.filter((parameter) => !isSystemStartParameter(parameter))
|
||||
.map((parameter) => ensureParameterId(parameter));
|
||||
|
||||
return [source.find((parameter) => isSystemStartParameter(parameter))!, ...customParameters];
|
||||
}
|
||||
|
||||
export function createInitialWorkflowData() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: `node_${genShortId()}`,
|
||||
type: START_NODE_TYPE,
|
||||
position: { x: 80, y: 180 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
} satisfies Node,
|
||||
],
|
||||
edges: [],
|
||||
viewport: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data: T): T {
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextNodes = data.nodes.map((node) => {
|
||||
if (node?.type !== START_NODE_TYPE) {
|
||||
return node;
|
||||
}
|
||||
const currentParameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
: [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return node;
|
||||
}
|
||||
const normalizedParameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data || {}),
|
||||
parameters: normalizedParameters,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
nodes: nextNodes,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWorkflowDataEmpty(data: any) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
||||
const edges = Array.isArray(data.edges) ? data.edges : [];
|
||||
return nodes.length === 0 && edges.length === 0;
|
||||
}
|
||||
|
||||
export function buildEditorReferenceParameters(
|
||||
currentNodeId: string,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
existingParameters?: Parameter[],
|
||||
) {
|
||||
const refNodeIds: string[] = [];
|
||||
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
|
||||
|
||||
const upstreamParameters = nodes
|
||||
.filter((node) => refNodeIds.includes(node.id))
|
||||
.flatMap((node) => getNodeReferenceParameters(node));
|
||||
|
||||
const upstreamNameSet = new Set(
|
||||
upstreamParameters.map((parameter) => asString(parameter.name).trim()),
|
||||
);
|
||||
|
||||
const disconnectedParameters = (existingParameters || [])
|
||||
.map((parameter) => ensureParameterId(parameter))
|
||||
.filter((parameter) => {
|
||||
const name = asString(parameter.name).trim();
|
||||
if (!name || upstreamNameSet.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return parameter.autoManaged === true;
|
||||
})
|
||||
.map((parameter) =>
|
||||
ensureParameterId({
|
||||
...parameter,
|
||||
resolved: false,
|
||||
disconnected: true,
|
||||
displayName: buildDisconnectedDisplayName(parameter),
|
||||
formLabel: buildDisconnectedDisplayName(parameter),
|
||||
} as Parameter & { resolved: boolean; disconnected: boolean }),
|
||||
);
|
||||
|
||||
return uniqueParameters([...disconnectedParameters, ...upstreamParameters]);
|
||||
}
|
||||
|
||||
export function syncManagedParametersForFields(
|
||||
existingParameters: Parameter[] | undefined,
|
||||
candidateParameters: Parameter[],
|
||||
fieldValues: Record<string, string | undefined>,
|
||||
) {
|
||||
const currentParameters = Array.isArray(existingParameters)
|
||||
? existingParameters.map(ensureParameterId)
|
||||
: [];
|
||||
const tokenKeys = Array.from(
|
||||
new Set(
|
||||
Object.values(fieldValues)
|
||||
.flatMap((value) => getTokenRanges(asString(value)).map((token) => token.key))
|
||||
.filter((value) => value.trim().length > 0),
|
||||
),
|
||||
);
|
||||
const usedTokenKeySet = new Set(tokenKeys);
|
||||
const candidateKeySet = new Set(
|
||||
candidateParameters
|
||||
.map((parameter) => asString(parameter.name).trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
const nextParameters = currentParameters
|
||||
.filter((parameter) => {
|
||||
if (parameter.autoManaged !== true) {
|
||||
return true;
|
||||
}
|
||||
const parameterName = asString(parameter.name).trim();
|
||||
return usedTokenKeySet.has(parameterName) && candidateKeySet.has(parameterName);
|
||||
})
|
||||
.map((parameter) => {
|
||||
if (parameter.autoManaged !== true) {
|
||||
return parameter;
|
||||
}
|
||||
const candidate = findParameterByName(
|
||||
candidateParameters,
|
||||
asString(parameter.name).trim(),
|
||||
) as (Parameter & { disconnected?: boolean }) | undefined;
|
||||
if (!candidate) {
|
||||
return parameter;
|
||||
}
|
||||
return {
|
||||
...parameter,
|
||||
displayName: candidate.displayName || parameter.displayName,
|
||||
formLabel: candidate.formLabel || parameter.formLabel,
|
||||
disconnected: candidate.disconnected === true ? true : undefined,
|
||||
} as Parameter;
|
||||
});
|
||||
|
||||
for (const tokenKey of tokenKeys) {
|
||||
if (!candidateKeySet.has(tokenKey)) {
|
||||
continue;
|
||||
}
|
||||
if (findParameterByName(nextParameters, tokenKey)) {
|
||||
continue;
|
||||
}
|
||||
const candidate = findParameterByName(candidateParameters, tokenKey);
|
||||
nextParameters.push(toManagedRefParameter(tokenKey, candidate));
|
||||
}
|
||||
|
||||
return uniqueParameters(nextParameters);
|
||||
}
|
||||
|
||||
export function toToken(refPath: string) {
|
||||
return `{{${refPath}}}`;
|
||||
}
|
||||
|
||||
function getFieldBindingMeta(data: Record<string, any>) {
|
||||
return ((data?.[FIELD_BINDING_META_KEY] || {}) as FieldBindingMeta) || {};
|
||||
}
|
||||
|
||||
export function updateFieldBindingMeta(
|
||||
currentData: Record<string, any>,
|
||||
fieldName: string,
|
||||
nextValue: string,
|
||||
) {
|
||||
const nextMeta: FieldBindingMeta = {
|
||||
...getFieldBindingMeta(currentData),
|
||||
};
|
||||
const currentFieldMeta = nextMeta[fieldName];
|
||||
if (!currentFieldMeta?.autoFilledFrom) {
|
||||
return nextMeta;
|
||||
}
|
||||
|
||||
nextMeta[fieldName] = {
|
||||
...currentFieldMeta,
|
||||
userModified: nextValue !== toToken(currentFieldMeta.autoFilledFrom),
|
||||
};
|
||||
return nextMeta;
|
||||
}
|
||||
|
||||
function resolveNearestStartNodeRef(
|
||||
targetNodeId: string,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
const queue = [targetNodeId];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
if (visited.has(currentId)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(currentId);
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.target !== currentId || !edge.source) {
|
||||
continue;
|
||||
}
|
||||
const sourceNode = nodes.find((node) => node.id === edge.source);
|
||||
if (!sourceNode) {
|
||||
continue;
|
||||
}
|
||||
if (sourceNode.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(sourceNode.data?.parameters)
|
||||
? ((sourceNode.data?.parameters as Parameter[]) || [])
|
||||
: [];
|
||||
const systemParam = parameters.find((parameter) =>
|
||||
isSystemStartParameter(parameter),
|
||||
);
|
||||
if (!systemParam) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
refPath: `${sourceNode.id}.${SYSTEM_START_PARAM_NAME}`,
|
||||
displayName: `${getNodeTitle(sourceNode)} > ${getParameterLabel(systemParam)}`,
|
||||
};
|
||||
}
|
||||
queue.push(sourceNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function canApplyAutoBinding(
|
||||
fieldValue: string | undefined,
|
||||
fieldMeta: FieldBindingMeta[string] | undefined,
|
||||
expectedRefPath: string,
|
||||
) {
|
||||
if (fieldMeta?.userModified) {
|
||||
return false;
|
||||
}
|
||||
const currentValue = asString(fieldValue).trim();
|
||||
if (!currentValue) {
|
||||
return true;
|
||||
}
|
||||
return currentValue === toToken(expectedRefPath);
|
||||
}
|
||||
|
||||
function getSupportedFieldKeys(nodeType: string | undefined) {
|
||||
if (nodeType === LLM_NODE_TYPE) {
|
||||
return ['systemPrompt', 'userPrompt'];
|
||||
}
|
||||
if (nodeType === KNOWLEDGE_NODE_TYPE) {
|
||||
return ['keyword', 'limit'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSingleRunFieldDescriptors(
|
||||
node: Pick<Node, 'type' | 'data'>,
|
||||
): SingleRunFieldDescriptor[] {
|
||||
if (!node?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (node.type === LLM_NODE_TYPE) {
|
||||
return [
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
label: '系统提示词',
|
||||
value: asString((node.data as Record<string, any>).systemPrompt),
|
||||
placeholder: '未设置',
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'userPrompt',
|
||||
label: '用户提示词',
|
||||
value: asString((node.data as Record<string, any>).userPrompt),
|
||||
placeholder: '未设置',
|
||||
multiline: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === KNOWLEDGE_NODE_TYPE) {
|
||||
return [
|
||||
{
|
||||
key: 'keyword',
|
||||
label: '关键词',
|
||||
value: asString((node.data as Record<string, any>).keyword),
|
||||
placeholder: '未设置',
|
||||
},
|
||||
{
|
||||
key: 'limit',
|
||||
label: '获取数据量',
|
||||
value: asString((node.data as Record<string, any>).limit),
|
||||
placeholder: '未设置',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getAutoBindingFieldKeys(nodeType: string | undefined) {
|
||||
if (nodeType === LLM_NODE_TYPE) {
|
||||
return ['userPrompt'];
|
||||
}
|
||||
if (nodeType === KNOWLEDGE_NODE_TYPE) {
|
||||
return ['keyword'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildFieldBindingPatch(
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
if (!node?.id || !node?.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldKeys = getSupportedFieldKeys(node.type);
|
||||
if (fieldKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (node.data || {}) as Record<string, any>;
|
||||
const startRef = resolveNearestStartNodeRef(node.id, nodes, edges);
|
||||
const patch: Record<string, any> = {};
|
||||
const nextFieldValues: Record<string, string | undefined> = Object.fromEntries(
|
||||
fieldKeys.map((fieldKey) => [fieldKey, asString(data[fieldKey])]),
|
||||
);
|
||||
const nextMeta: FieldBindingMeta = {
|
||||
...getFieldBindingMeta(data),
|
||||
};
|
||||
|
||||
const applyField = (fieldName: string) => {
|
||||
if (!startRef) {
|
||||
return;
|
||||
}
|
||||
const fieldMeta = nextMeta[fieldName];
|
||||
if (!canApplyAutoBinding(nextFieldValues[fieldName], fieldMeta, startRef.refPath)) {
|
||||
return;
|
||||
}
|
||||
const tokenValue = toToken(startRef.refPath);
|
||||
if (asString(nextFieldValues[fieldName]).trim() !== tokenValue) {
|
||||
patch[fieldName] = tokenValue;
|
||||
}
|
||||
nextFieldValues[fieldName] = tokenValue;
|
||||
nextMeta[fieldName] = {
|
||||
autoFilledFrom: startRef.refPath,
|
||||
userModified: false,
|
||||
};
|
||||
};
|
||||
|
||||
const clearField = (fieldName: string, fieldMeta: NonNullable<FieldBindingMeta[string]>) => {
|
||||
const currentValue = asString(nextFieldValues[fieldName]).trim();
|
||||
const autoTokenValue = toToken(fieldMeta.autoFilledFrom || '');
|
||||
if (currentValue && currentValue !== autoTokenValue) {
|
||||
return;
|
||||
}
|
||||
if (currentValue) {
|
||||
patch[fieldName] = '';
|
||||
}
|
||||
nextFieldValues[fieldName] = '';
|
||||
delete nextMeta[fieldName];
|
||||
};
|
||||
|
||||
for (const fieldName of getAutoBindingFieldKeys(node.type)) {
|
||||
const fieldMeta = nextMeta[fieldName];
|
||||
if (!fieldMeta?.autoFilledFrom || fieldMeta.userModified) {
|
||||
continue;
|
||||
}
|
||||
if (startRef?.refPath === fieldMeta.autoFilledFrom) {
|
||||
continue;
|
||||
}
|
||||
clearField(fieldName, fieldMeta);
|
||||
}
|
||||
|
||||
for (const fieldName of getAutoBindingFieldKeys(node.type)) {
|
||||
applyField(fieldName);
|
||||
}
|
||||
|
||||
const editorParameters = buildEditorReferenceParameters(
|
||||
node.id,
|
||||
nodes,
|
||||
edges,
|
||||
(data.parameters as Parameter[]) || [],
|
||||
);
|
||||
patch.parameters = syncManagedParametersForFields(
|
||||
(data.parameters as Parameter[]) || [],
|
||||
editorParameters,
|
||||
nextFieldValues,
|
||||
);
|
||||
patch[FIELD_BINDING_META_KEY] = nextMeta;
|
||||
|
||||
const currentParameters = JSON.stringify((data.parameters as Parameter[]) || []);
|
||||
if (JSON.stringify(patch.parameters) === currentParameters) {
|
||||
delete patch.parameters;
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(nextMeta) === JSON.stringify(getFieldBindingMeta(data))
|
||||
) {
|
||||
delete patch[FIELD_BINDING_META_KEY];
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function buildAutoBindingPatch(
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
return buildFieldBindingPatch(node, nodes, edges);
|
||||
}
|
||||
|
||||
export function buildSequentialFieldBindingPatches(
|
||||
nodeIds: string[],
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
const uniqueNodeIds = Array.from(
|
||||
new Set(nodeIds.map((nodeId) => asString(nodeId).trim()).filter(Boolean)),
|
||||
);
|
||||
if (uniqueNodeIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workingNodes = nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...((node.data || {}) as Record<string, any>),
|
||||
},
|
||||
}));
|
||||
const patches: Array<{ nodeId: string; patch: Record<string, any> }> = [];
|
||||
|
||||
for (const nodeId of uniqueNodeIds) {
|
||||
const nodeIndex = workingNodes.findIndex((item) => item.id === nodeId);
|
||||
if (nodeIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const currentNode = workingNodes[nodeIndex]!;
|
||||
const patch = buildFieldBindingPatch(currentNode, workingNodes, edges);
|
||||
if (!patch) {
|
||||
continue;
|
||||
}
|
||||
patches.push({ nodeId, patch });
|
||||
workingNodes[nodeIndex] = {
|
||||
...currentNode,
|
||||
data: {
|
||||
...((currentNode.data || {}) as Record<string, any>),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | null | undefined) {
|
||||
if (!node?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? ((node.data.parameters as Parameter[]) || [])
|
||||
: [];
|
||||
return hasSystemStartParameter(parameters)
|
||||
? ensureStartNodeParameters(parameters)
|
||||
: parameters;
|
||||
}
|
||||
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? (node.data.parameters as Parameter[]).map(ensureParameterId)
|
||||
: [];
|
||||
|
||||
const fieldKeys =
|
||||
node.type === LLM_NODE_TYPE
|
||||
? ['systemPrompt', 'userPrompt']
|
||||
: node.type === KNOWLEDGE_NODE_TYPE
|
||||
? ['keyword', 'limit']
|
||||
: [];
|
||||
|
||||
if (fieldKeys.length === 0) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const usedTokenKeys = Array.from(
|
||||
new Set(
|
||||
fieldKeys.flatMap((fieldKey) =>
|
||||
getTokenRanges(asString((node.data as Record<string, any>)[fieldKey])).map(
|
||||
(token) => token.key,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return parameters.filter((parameter) =>
|
||||
usedTokenKeys.includes(asString(parameter.name).trim()),
|
||||
).map((parameter) =>
|
||||
ensureParameterId({
|
||||
...parameter,
|
||||
required: parameter.required ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSingleRunModel(
|
||||
node: Pick<Node, 'type' | 'data'> | null | undefined,
|
||||
): SingleRunModel {
|
||||
const parameters = buildSingleRunParameters(node);
|
||||
if (!node?.data) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
const fields = getSingleRunFieldDescriptors(node);
|
||||
if (fields.length === 0) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'fields',
|
||||
parameters,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user