fix: 修复工作流入口标题与提示词校验

- 支持系统入口标题清空占位与下游引用展示同步

- 降低字段编辑触发的节点图重算和无效渲染

- 在保存及执行前拦截空白的大模型用户提示词
This commit is contained in:
2026-08-10 23:15:41 +08:00
parent 6bfd440214
commit 1c0fbfa5ff
5 changed files with 482 additions and 58 deletions

View File

@@ -832,11 +832,36 @@ public class WorkflowCheckService {
continue;
}
if (workflowDatacenterContentService.isLlmNode(node.type)) {
checkLlmUserPrompt(node, issues, issueKeys);
checkLlmQueryContext(node, parsed, issues, issueKeys);
}
}
}
/**
* 校验大模型节点的用户提示词,避免空提示词进入运行时解析。
*
* @param node 大模型节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkLlmUserPrompt(
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
String userPrompt = node.data == null ? null : node.data.getString("userPrompt");
if (!StringUtils.hasText(userPrompt)) {
addIssue(
issues,
issueKeys,
"LLM_USER_PROMPT_EMPTY",
"大模型节点的用户提示词不能为空",
node.id,
null,
node.name);
}
}
private void checkMakeFileNode(NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {

View File

@@ -436,6 +436,41 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "SEARCH_DATASET_INVALID");
}
/**
* 验证保存阶段拒绝空白的大模型用户提示词。
*/
@Test
public void testSaveShouldBlockBlankLlmUserPrompt() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject llmData = data("大模型");
llmData.put("userPrompt", " ");
String content = workflowJson(
array(node("llm-1", "llmNode", null, llmData)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LLM_USER_PROMPT_EMPTY");
}
/**
* 验证保存阶段接受有效的大模型用户提示词。
*/
@Test
public void testSaveShouldPassNonBlankLlmUserPrompt() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject llmData = data("大模型");
llmData.put("userPrompt", "{{start-1.user_input}}");
String content = workflowJson(
array(node("llm-1", "llmNode", null, llmData)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
@Test
public void testPreExecuteShouldBlockMissingStartOrEnd() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
@@ -453,6 +488,34 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "END_NODE_MISSING");
}
/**
* 验证执行前校验拒绝空的大模型用户提示词。
*/
@Test
public void testPreExecuteShouldBlockEmptyLlmUserPrompt() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject llmData = data("大模型");
llmData.put("userPrompt", "");
String content = workflowJson(
array(
node("s1", "startNode", null, data("开始")),
node("llm-1", "llmNode", null, llmData),
node("e1", "endNode", null, data("结束"))
),
array(
edge("edge-1", "s1", "llm-1"),
edge("edge-2", "llm-1", "e1")
)
);
WorkflowCheckResult result = service.checkContent(
content,
WorkflowCheckStage.PRE_EXECUTE,
BigInteger.ONE);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LLM_USER_PROMPT_EMPTY");
}
@Test
public void testPreExecuteShouldPassForSourceOnlySearchDatasetNode() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());

View File

@@ -52,9 +52,10 @@
return param.formType ? [param.formType] : [];
});
let parameterNameDraft = $state<string | null>(null);
let systemLabelDraft = $state<string | null>(null);
let displayParamName = $derived.by(() => {
if (isSystemStartParam) {
return '用户问题';
return systemLabelDraft ?? trimString(param.formLabel);
}
return parameterNameDraft ?? param.name;
});
@@ -76,6 +77,8 @@
const applyStartFieldPatch = (fieldKey: string, patch: Record<string, any>) => {
const currentFieldId = trimString(param.id);
const shouldSyncReferences = Object.prototype.hasOwnProperty.call(patch, 'key')
|| Object.prototype.hasOwnProperty.call(patch, 'label');
store.updateNodes((nodes) => {
const edges = store.getEdges();
let nextFieldKey = fieldKey;
@@ -98,6 +101,9 @@
}
};
});
if (!shouldSyncReferences) {
return nextNodes;
}
return renameStartFieldReferencesInNodes(
nextNodes,
edges,
@@ -172,11 +178,11 @@
const updateName = (event: Event) => {
const input = event.target as HTMLInputElement;
const newValue = input.value;
parameterNameDraft = newValue;
if (isStartNodeInputParam) {
const normalizedValue = trimString(newValue);
if (!normalizedValue) {
parameterNameError = '参数名不能为空';
parameterNameDraft = newValue;
return;
}
if (!isStartFormFieldKeyAvailable(
@@ -185,13 +191,66 @@
normalizedValue
)) {
parameterNameError = '参数名已存在';
parameterNameDraft = newValue;
return;
}
}
parameterNameError = '';
};
const updateVisibleName = (event: Event) => {
if (isSystemStartParam) {
systemLabelDraft = (event.target as HTMLInputElement).value;
return;
}
updateName(event);
};
const commitName = () => {
if (parameterNameDraft === null) {
return;
}
const draftValue = parameterNameDraft;
if (isStartNodeInputParam) {
const normalizedValue = trimString(draftValue);
if (!normalizedValue) {
parameterNameError = '参数名不能为空';
return;
}
if (!isStartFormFieldKeyAvailable(
node?.current?.data as Record<string, any>,
param.name || '',
normalizedValue
)) {
parameterNameError = '参数名已存在';
return;
}
parameterNameDraft = null;
parameterNameError = '';
if (normalizedValue !== trimString(param.name)) {
updateParameter('name', normalizedValue);
}
return;
}
parameterNameDraft = null;
parameterNameError = '';
updateParameter('name', newValue);
if (draftValue !== (param.name || '')) {
updateParameter('name', draftValue);
}
};
const commitVisibleName = () => {
if (!isSystemStartParam) {
commitName();
return;
}
if (systemLabelDraft === null) {
return;
}
const nextLabel = trimString(systemLabelDraft);
systemLabelDraft = null;
if (nextLabel !== trimString(param.formLabel)) {
updateParameter('formLabel', nextLabel);
}
};
const updateRequired = (event: Event) => {
@@ -236,11 +295,13 @@
<div class="input-item input-item-name">
<Input style="width: 100%;" value={displayParamName} placeholder="请输入参数名称"
disabled={param.nameDisabled === true}
<Input style="width: 100%;" value={displayParamName}
placeholder={isSystemStartParam ? '用户问题' : '请输入参数名称'}
disabled={isSystemStartParam ? false : param.nameDisabled === true}
aria-invalid={parameterNameError ? 'true' : undefined}
aria-describedby={parameterNameError ? parameterNameErrorId : undefined}
oninput={updateName} />
oninput={updateVisibleName}
onchange={commitVisibleName} />
{#if parameterNameError}
<div id={parameterNameErrorId} class="input-error" role="alert">
{parameterNameError}
@@ -296,7 +357,7 @@
数据标题:
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formLabel', event)
}} value={param.formLabel} />
}} value={param.formLabel} placeholder={isSystemStartParam ? '用户问题' : undefined} />
</div>
<div class="input-more-item">

View File

@@ -34,18 +34,57 @@ describe('workflow node fields', () => {
expect(parameters[0]?.name).toBe('user_input');
expect(parameters[0]?.systemReserved).toBe(true);
expect(parameters[0]?.required).toBe(true);
expect(parameters[0]?.formLabel).toBe('');
expect(parameters[0]?.displayName).toBe('流程开始 > 用户问题');
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
title: '开始问答',
submitText: '开始',
});
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
key: 'user_input',
label: '',
type: 'textarea',
systemReserved: true,
required: true,
});
});
it('preserves an empty system start title for the default placeholder', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const customized = updateStartFormField(startData, 'user_input', {
label: '反洗钱',
});
const cleared = updateStartFormField(customized, 'user_input', {
label: '',
});
expect(customized.startFormSchema?.[0]?.label).toBe('反洗钱');
expect(cleared.startFormSchema?.[0]?.label).toBe('');
expect((cleared.parameters as any[])?.[0]?.formLabel).toBe('');
expect((cleared.parameters as any[])?.[0]?.displayName).toBe(
'流程开始 > 用户问题',
);
});
it('preserves an explicitly entered default or prefixed system title', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const defaultTitle = updateStartFormField(startData, 'user_input', {
label: '用户问题',
});
const prefixedTitle = updateStartFormField(startData, 'user_input', {
label: '用户问题说明',
});
expect(defaultTitle.startFormSchema?.[0]?.label).toBe('用户问题');
expect((defaultTitle.parameters as any[])?.[0]?.formLabel).toBe('用户问题');
expect(prefixedTitle.startFormSchema?.[0]?.label).toBe('用户问题说明');
expect((prefixedTitle.parameters as any[])?.[0]?.formLabel).toBe(
'用户问题说明',
);
});
it('appends custom start form field into schema source of truth', () => {
const initial = createInitialWorkflowData();
const startNode = initial.nodes[0]!;
@@ -326,6 +365,129 @@ describe('workflow node fields', () => {
});
});
it('refreshes downstream reference labels when the system title changes', () => {
const initial = createInitialWorkflowData();
const customStartData = updateStartFormField(
initial.nodes[0]?.data as Record<string, any>,
'user_input',
{ label: '反洗钱' },
);
const clearedStartData = updateStartFormField(
customStartData,
'user_input',
{ label: '' },
);
const startNode: Node = {
id: 'start_1',
type: 'startNode',
position: { x: 0, y: 0 },
data: {
title: '开始节点',
...clearedStartData,
},
};
const llmNode: Node = {
id: 'llm_1',
type: 'llmNode',
position: { x: 120, y: 0 },
data: {
title: '大模型',
userPrompt: '',
parameters: [
{
id: 'param_1',
name: 'start_1.user_input',
ref: 'start_1.user_input',
refType: 'ref',
autoManaged: true,
formLabel: '开始节点 > 反洗钱',
displayName: '开始节点 > 反洗钱',
},
],
},
};
const endNode: Node = {
id: 'end_1',
type: 'endNode',
position: { x: 240, y: 0 },
data: {
title: '结束节点',
parameters: [
{
id: 'param_2',
name: 'start_1.user_input',
ref: 'start_1.user_input',
refType: 'ref',
formLabel: '开始节点 > 反洗钱',
displayName: '开始节点 > 反洗钱',
},
],
},
};
const edges: Edge[] = [
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
{ id: 'edge_2', source: 'llm_1', target: 'end_1' } as Edge,
];
const nextNodes = renameStartFieldReferencesInNodes(
[startNode, llmNode, endNode],
edges,
'start_1',
'user_input',
'user_input',
);
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
const nextEndParameter = (
nextNodes.find((node) => node.id === 'end_1')?.data?.parameters as any[]
)?.[0];
expect(nextLlmNode.data?.userPrompt).toBe('');
expect(nextParameter?.name).toBe('start_1.user_input');
expect(nextParameter?.ref).toBe('start_1.user_input');
expect(nextParameter?.displayName).toBe('开始节点 > 用户问题');
expect(nextParameter?.formLabel).toBe('开始节点 > 用户问题');
expect(nextEndParameter?.displayName).toBe('开始节点 > 用户问题');
expect(nextEndParameter?.formLabel).toBe('开始节点 > 用户问题');
expect((nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]).toBeUndefined();
});
it('preserves node identities when a title sync has nothing to update', () => {
const initial = createInitialWorkflowData();
const startNode: Node = {
...initial.nodes[0]!,
id: 'start_1',
};
const untouchedNode: Node = {
id: 'code_1',
type: 'codeNode',
position: { x: 120, y: 0 },
data: {
title: '代码节点',
parameters: [
{
id: 'param_1',
name: 'manual_value',
displayName: '手工参数',
},
],
},
};
const nodes = [startNode, untouchedNode];
const nextNodes = renameStartFieldReferencesInNodes(
nodes,
[],
'start_1',
'user_input',
'user_input',
);
expect(nextNodes).toBe(nodes);
expect(nextNodes[0]).toBe(startNode);
expect(nextNodes[1]).toBe(untouchedNode);
});
it('preserves resource content type independently from input type', () => {
for (const contentType of ['image', 'video', 'audio', 'other'] as const) {
const key = `preview_${contentType}`;

View File

@@ -184,6 +184,9 @@ function getNodeTitle(node?: Node | null) {
}
function getParameterLabel(parameter?: Parameter | null) {
if (isSystemStartParameter(parameter)) {
return trimString(parameter?.formLabel) || SYSTEM_START_PARAM_LABEL;
}
return (
asString(parameter?.formLabel).trim() ||
asString(parameter?.displayName).trim() ||
@@ -415,6 +418,14 @@ function normalizeSystemStartFormType(value: unknown): 'input' | 'textarea' {
return trimString(value) === 'input' ? 'input' : 'textarea';
}
function normalizeSystemStartFormLabel(value: unknown) {
return trimString(value);
}
function getSystemStartDisplayLabel(value: unknown) {
return normalizeSystemStartFormLabel(value) || SYSTEM_START_PARAM_LABEL;
}
function normalizeStartFormFieldUiType(
value: unknown,
fallback: StartFormFieldType = 'text',
@@ -519,8 +530,8 @@ export function normalizeStartFormMeta(
function normalizeSystemStartParameter(
parameter?: Parameter | null,
): Parameter {
const fallbackLabel = SYSTEM_START_PARAM_LABEL;
const formLabel = trimString(parameter?.formLabel) || fallbackLabel;
const formLabel = normalizeSystemStartFormLabel(parameter?.formLabel);
const displayLabel = getSystemStartDisplayLabel(formLabel);
const formType = normalizeSystemStartFormType(parameter?.formType);
return ensureParameterId({
...cloneParameter(parameter || {}),
@@ -534,7 +545,7 @@ function normalizeSystemStartParameter(
formDescription: asString(parameter?.formDescription),
formPlaceholder: trimString(parameter?.formPlaceholder) || '请输入用户问题',
defaultValue: asString(parameter?.defaultValue),
displayName: `流程开始 > ${formLabel}`,
displayName: `流程开始 > ${displayLabel}`,
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true,
@@ -637,11 +648,18 @@ function normalizeStartFormField(
const options = isOptionFieldType(type)
? ensureStringArray(field?.options || existingParameter?.enums)
: [];
const requestedLabel =
trimString(field?.label) ||
trimString(existingParameter?.formLabel) ||
trimString(existingParameter?.displayName) ||
key;
const hasFieldLabel = Object.prototype.hasOwnProperty.call(
field || {},
'label',
);
const requestedLabel = isSystemField
? normalizeSystemStartFormLabel(
hasFieldLabel ? field?.label : existingParameter?.formLabel,
)
: trimString(field?.label) ||
trimString(existingParameter?.formLabel) ||
trimString(existingParameter?.displayName) ||
key;
const label =
!isSystemField &&
isDefaultStartFormFieldLabel(requestedLabel) &&
@@ -796,8 +814,7 @@ export function normalizeStartFormSchema(
{
id: trimString(systemParameter?.id),
key: SYSTEM_START_PARAM_NAME,
label:
trimString(systemParameter?.formLabel) || SYSTEM_START_PARAM_LABEL,
label: normalizeSystemStartFormLabel(systemParameter?.formLabel),
type:
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
? 'text'
@@ -933,6 +950,7 @@ export function updateStartFormField(
!trimString(patch.label) &&
patch.type != null &&
isDefaultStartFormFieldLabel(field.label);
const hasLabelPatch = Object.prototype.hasOwnProperty.call(patch, 'label');
return {
...field,
...patch,
@@ -947,10 +965,12 @@ export function updateStartFormField(
)
: field.key),
label:
trimString(patch.label) ||
(shouldAutoRenameLabel
? getDefaultStartFormFieldLabel(nextType)
: field.label),
field.systemReserved && hasLabelPatch
? normalizeSystemStartFormLabel(patch.label)
: trimString(patch.label) ||
(shouldAutoRenameLabel
? getDefaultStartFormFieldLabel(nextType)
: field.label),
type: nextType,
};
});
@@ -1450,28 +1470,115 @@ function replaceStartFieldReferenceValue(
export function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
const nodeIds = new Set<string>();
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (!edge.source || !edge.target || edge.sourceHandle === 'loop_handle') {
continue;
}
const targets = adjacency.get(edge.source);
if (targets) {
targets.push(edge.target);
} else {
adjacency.set(edge.source, [edge.target]);
}
}
const visit = (nodeId: string) => {
const pendingNodeIds = [rootNodeId];
while (pendingNodeIds.length > 0) {
const nodeId = pendingNodeIds.pop();
if (!nodeId || nodeIds.has(nodeId)) {
return;
continue;
}
nodeIds.add(nodeId);
edges
.filter(
(edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle',
)
.forEach((edge) => {
if (edge.target) {
visit(edge.target);
}
});
};
const targets = adjacency.get(nodeId) || [];
for (let index = targets.length - 1; index >= 0; index -= 1) {
pendingNodeIds.push(targets[index]!);
}
}
visit(rootNodeId);
nodeIds.delete(rootNodeId);
return Array.from(nodeIds);
}
function syncReferenceParameterLabels(
parameters: Parameter[],
refPath: string,
displayName: string,
): Parameter[] {
let changed = false;
const nextParameters = parameters.map((parameter) => {
const currentChildren = Array.isArray(parameter.children)
? parameter.children
: [];
const nextChildren = currentChildren.length
? syncReferenceParameterLabels(currentChildren, refPath, displayName)
: currentChildren;
const matchesReference =
(parameter.autoManaged === true || parameter.refType === 'ref') &&
(trimString(parameter.name) === refPath ||
trimString(parameter.ref) === refPath);
const labelChanged =
matchesReference &&
(parameter.displayName !== displayName ||
parameter.formLabel !== displayName);
const childrenChanged = nextChildren !== currentChildren;
if (!labelChanged && !childrenChanged) {
return parameter;
}
changed = true;
return {
...parameter,
...(labelChanged ? { displayName, formLabel: displayName } : {}),
...(childrenChanged ? { children: nextChildren } : {}),
};
});
return changed ? nextParameters : parameters;
}
function syncStartFieldReferenceLabelsInNodes(
nodes: Node[],
startNodeId: string,
fieldKey: string,
) {
const startNode = nodes.find((node) => node.id === startNodeId);
const startParameters = Array.isArray(startNode?.data?.parameters)
? (startNode.data.parameters as Parameter[])
: [];
const sourceParameter = findParameterByName(startParameters, fieldKey);
if (!startNode || !sourceParameter) {
return nodes;
}
const refPath = `${startNodeId}.${fieldKey}`;
const displayName = `${getNodeTitle(startNode)} > ${getReferenceParameterLabel(sourceParameter)}`;
let changed = false;
const nextNodes = nodes.map((node) => {
const parameters = Array.isArray(node.data?.parameters)
? (node.data.parameters as Parameter[])
: [];
if (parameters.length === 0) {
return node;
}
const nextParameters = syncReferenceParameterLabels(
parameters,
refPath,
displayName,
);
if (nextParameters === parameters) {
return node;
}
changed = true;
return {
...node,
data: {
...((node.data || {}) as Record<string, any>),
parameters: nextParameters,
},
};
});
return changed ? nextNodes : nodes;
}
export function renameStartFieldReferencesInNodes(
nodes: Node[],
edges: Edge[],
@@ -1482,35 +1589,41 @@ export function renameStartFieldReferencesInNodes(
const normalizedStartNodeId = trimString(startNodeId);
const normalizedCurrentKey = trimString(currentKey);
const normalizedNextKey = trimString(nextKey);
if (
!normalizedStartNodeId ||
!normalizedCurrentKey ||
!normalizedNextKey ||
normalizedCurrentKey === normalizedNextKey
) {
if (!normalizedStartNodeId || !normalizedCurrentKey || !normalizedNextKey) {
return nodes;
}
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
const nextNodes: Node[] = 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 keyChanged = normalizedCurrentKey !== normalizedNextKey;
const renamedNodes: Node[] = keyChanged
? 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 ?? {},
};
})
: nodes;
const nextNodes = syncStartFieldReferenceLabelsInNodes(
renamedNodes,
normalizedStartNodeId,
normalizedNextKey,
);
if (!keyChanged) {
return nextNodes;
}
const affectedNodeIds = collectDownstreamNodeIds(
normalizedStartNodeId,