feat: 优化工作流字段化参数配置

- 开始节点固定 user_input 并区分系统入口与自定义参数

- LLM 与知识库节点切换为字段值加上游引用配置

- 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
2026-04-12 20:31:02 +08:00
parent 47655a728b
commit 8cfe5400fe
24 changed files with 2785 additions and 792 deletions

View File

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

View File

@@ -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,
};
}