Commit c21783ed by siigure Committed by GitHub

chore(workflow): update workflowUtilsContext and add normalization fo… (#7324)

* chore(workflow): update workflowUtilsContext and add normalization for auto-fill references

* feat(workflow): enhance workflow data handling by filtering unselectable reference inputs
parent 210ab7ce
...@@ -108,7 +108,11 @@ export const useDebug = () => { ...@@ -108,7 +108,11 @@ export const useDebug = () => {
if (!hasError) { if (!hasError) {
onRemoveError(); onRemoveError();
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges }); const storeNodes = uiWorkflow2StoreWorkflow({
nodes,
edges,
chatConfig: appDetail.chatConfig
});
return JSON.stringify(storeNodes); return JSON.stringify(storeNodes);
} }
......
// 工作流快照管理层 // 工作流快照管理层
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector'; import { createContext, useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import type { Node, Edge } from 'reactflow'; import type { Node, Edge } from 'reactflow';
...@@ -61,7 +61,7 @@ type WorkflowSnapshotContextValue = { ...@@ -61,7 +61,7 @@ type WorkflowSnapshotContextValue = {
}; };
export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValue>({ export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValue>({
past: [], past: [],
setPast: function (value: React.SetStateAction<WorkflowSnapshotsType[]>): void { setPast: function (_value: React.SetStateAction<WorkflowSnapshotsType[]>): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
future: [], future: [],
...@@ -73,7 +73,7 @@ export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValu ...@@ -73,7 +73,7 @@ export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValu
}, },
canUndo: false, canUndo: false,
canRedo: false, canRedo: false,
pushPastSnapshot: function (params: { pushPastSnapshot: function (_params: {
pastNodes: Node[]; pastNodes: Node[];
pastEdges: Edge[]; pastEdges: Edge[];
chatConfig: AppChatConfigType; chatConfig: AppChatConfigType;
...@@ -82,10 +82,10 @@ export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValu ...@@ -82,10 +82,10 @@ export const WorkflowSnapshotContext = createContext<WorkflowSnapshotContextValu
}): boolean { }): boolean {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onSwitchTmpVersion: function (data: WorkflowSnapshotsType, customTitle: string): boolean { onSwitchTmpVersion: function (_data: WorkflowSnapshotsType, _customTitle: string): boolean {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onSwitchCloudVersion: function (appVersion: AppVersionSchemaType): boolean { onSwitchCloudVersion: function (_appVersion: AppVersionSchemaType): boolean {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
} }
}); });
...@@ -98,10 +98,11 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -98,10 +98,11 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
const { t } = useTranslation(); const { t } = useTranslation();
// 获取 WorkflowBufferDataContext 的数据 // 获取 WorkflowBufferDataContext 的数据
const { setEdges, setNodes, forbiddenSaveSnapshot } = useContextSelector( const {
WorkflowBufferDataContext, setEdges,
(v) => v setNodes,
); forbiddenSaveSnapshot: forbiddenSaveSnapshotRef
} = useContextSelector(WorkflowBufferDataContext, (v) => v);
// 获取 AppContext 的 setAppDetail // 获取 AppContext 的 setAppDetail
const setAppDetail = useContextSelector(AppContext, (v) => v.setAppDetail); const setAppDetail = useContextSelector(AppContext, (v) => v.setAppDetail);
...@@ -109,6 +110,10 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -109,6 +110,10 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
const [past, setPast] = useState<WorkflowSnapshotsType[]>([]); const [past, setPast] = useState<WorkflowSnapshotsType[]>([]);
const [future, setFuture] = useState<WorkflowSnapshotsType[]>([]); const [future, setFuture] = useState<WorkflowSnapshotsType[]>([]);
const pushPastSnapshotRef = useRef<WorkflowSnapshotContextValue['pushPastSnapshot'] | undefined>(
undefined
);
// 待保存快照队列机制 - 解决竞态条件,确保数据不丢失 // 待保存快照队列机制 - 解决竞态条件,确保数据不丢失
const pendingSnapshotRef = useRef<{ const pendingSnapshotRef = useRef<{
data: { data: {
...@@ -154,8 +159,8 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -154,8 +159,8 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
} }
// 3. 处理被阻塞的快照 // 3. 处理被阻塞的快照
if (forbiddenSaveSnapshot.current) { if (forbiddenSaveSnapshotRef.current) {
forbiddenSaveSnapshot.current = false; forbiddenSaveSnapshotRef.current = false;
console.warn('[Snapshot] Snapshot creation blocked, adding to pending queue'); console.warn('[Snapshot] Snapshot creation blocked, adding to pending queue');
// 将快照加入待处理队列 // 将快照加入待处理队列
...@@ -169,7 +174,7 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -169,7 +174,7 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
pendingSnapshotRef.current.timeoutId = setTimeout(() => { pendingSnapshotRef.current.timeoutId = setTimeout(() => {
if (pendingSnapshotRef.current?.data) { if (pendingSnapshotRef.current?.data) {
console.log('[Snapshot] Processing pending snapshot from queue'); console.log('[Snapshot] Processing pending snapshot from queue');
pushPastSnapshot(pendingSnapshotRef.current.data); pushPastSnapshotRef.current?.(pendingSnapshotRef.current.data);
pendingSnapshotRef.current = { data: null }; pendingSnapshotRef.current = { data: null };
} else { } else {
console.log('[Snapshot] No pending snapshot to process'); console.log('[Snapshot] No pending snapshot to process');
...@@ -236,12 +241,16 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -236,12 +241,16 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
return false; return false;
} }
}, },
[past, forbiddenSaveSnapshot] [past, forbiddenSaveSnapshotRef]
); );
useEffect(() => {
pushPastSnapshotRef.current = pushPastSnapshot;
}, [pushPastSnapshot]);
const undo = useCallback(() => { const undo = useCallback(() => {
if (past.length > 1) { if (past.length > 1) {
forbiddenSaveSnapshot.current = true; forbiddenSaveSnapshotRef.current = true;
// Current version is the first one, so we need to reset the second one // Current version is the first one, so we need to reset the second one
const firstPast = past[1]; const firstPast = past[1];
resetSnapshot(firstPast); resetSnapshot(firstPast);
...@@ -249,7 +258,7 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -249,7 +258,7 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
setFuture((future) => [past[0], ...future]); setFuture((future) => [past[0], ...future]);
setPast((past) => past.slice(1)); setPast((past) => past.slice(1));
} }
}, [past, resetSnapshot, forbiddenSaveSnapshot]); }, [past, resetSnapshot, forbiddenSaveSnapshotRef]);
const redo = useCallback(() => { const redo = useCallback(() => {
if (!future[0]) return; if (!future[0]) return;
...@@ -257,13 +266,13 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -257,13 +266,13 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
const futureState = future[0]; const futureState = future[0];
if (futureState) { if (futureState) {
forbiddenSaveSnapshot.current = true; forbiddenSaveSnapshotRef.current = true;
setPast((past) => [futureState, ...past]); setPast((past) => [futureState, ...past]);
setFuture((future) => future.slice(1)); setFuture((future) => future.slice(1));
resetSnapshot(futureState); resetSnapshot(futureState);
} }
}, [future, resetSnapshot, forbiddenSaveSnapshot]); }, [future, resetSnapshot, forbiddenSaveSnapshotRef]);
const onSwitchTmpVersion = useCallback( const onSwitchTmpVersion = useCallback(
(params: WorkflowSnapshotsType, customTitle: string) => { (params: WorkflowSnapshotsType, customTitle: string) => {
...@@ -286,8 +295,8 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo ...@@ -286,8 +295,8 @@ export const WorkflowSnapshotProvider = ({ children }: { children: React.ReactNo
const onSwitchCloudVersion = useCallback( const onSwitchCloudVersion = useCallback(
(appVersion: AppVersionSchemaType) => { (appVersion: AppVersionSchemaType) => {
const nodes = appVersion.nodes.map((item) => storeNode2FlowNode({ item, t }));
const edges = appVersion.edges.map((item) => storeEdge2RenderEdge({ edge: item })); const edges = appVersion.edges.map((item) => storeEdge2RenderEdge({ edge: item }));
const nodes = appVersion.nodes.map((item) => storeNode2FlowNode({ item, t }));
const chatConfig = appVersion.chatConfig; const chatConfig = appVersion.chatConfig;
resetSnapshot({ resetSnapshot({
......
...@@ -167,8 +167,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -167,8 +167,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
// 将 UI 流程数据转换为存储格式 // 将 UI 流程数据转换为存储格式
const flowData2StoreData = useCallback(() => { const flowData2StoreData = useCallback(() => {
const nodes = getNodes(); const nodes = getNodes();
return uiWorkflow2StoreWorkflow({ nodes, edges }); return uiWorkflow2StoreWorkflow({ nodes, edges, chatConfig: appDetail.chatConfig });
}, [getNodes, edges]); }, [getNodes, edges, appDetail.chatConfig]);
// 转换并验证工作流数据 // 转换并验证工作流数据
const flowData2StoreDataAndCheck = useCallback( const flowData2StoreDataAndCheck = useCallback(
...@@ -214,7 +214,11 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -214,7 +214,11 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
if (!hasError) { if (!hasError) {
onRemoveError(); onRemoveError();
const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges }); const storeWorkflow = uiWorkflow2StoreWorkflow({
nodes,
edges,
chatConfig: appDetail.chatConfig
});
return storeWorkflow; return storeWorkflow;
} }
...@@ -249,6 +253,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -249,6 +253,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
onUpdateNodeError, onUpdateNodeError,
showSandbox, showSandbox,
enableSandbox, enableSandbox,
appDetail.chatConfig,
toast toast
] ]
); );
......
import { getNodeAllSource } from '@/web/core/workflow/utils'; import { getNodeAllSource, workflowReferenceValueIsSelectable } from '@/web/core/workflow/utils';
import { type AppDetailType } from '@fastgpt/global/core/app/type'; import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { import {
FlowNodeOutputTypeEnum, FlowNodeOutputTypeEnum,
...@@ -10,16 +10,30 @@ import { ...@@ -10,16 +10,30 @@ import {
type FlowNodeItemType, type FlowNodeItemType,
type StoreNodeItemType type StoreNodeItemType
} from '@fastgpt/global/core/workflow/type/node'; } from '@fastgpt/global/core/workflow/type/node';
import type {
FlowNodeInputItemType,
ReferenceItemValueType,
ReferenceValueType
} from '@fastgpt/global/core/workflow/type/io';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { type TFunction } from 'i18next'; import { type TFunction } from 'i18next';
import { type Edge, type Node } from 'reactflow'; import { type Edge, type Node } from 'reactflow';
export const uiWorkflow2StoreWorkflow = ({ export const uiWorkflow2StoreWorkflow = ({
nodes, nodes,
edges edges,
chatConfig
}: { }: {
nodes: Node<FlowNodeItemType, string | undefined>[]; nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[]; edges: Edge<any>[];
chatConfig?: AppChatConfigType;
}) => { }) => {
const getNodeById = (nodeId: string | null | undefined) =>
nodes.find((node) => node.data.nodeId === nodeId)?.data;
const systemConfigNode = nodes.find(
(node) => node.data.flowNodeType === FlowNodeTypeEnum.systemConfig
)?.data;
const formatNodes: StoreNodeItemType[] = nodes.map((item) => ({ const formatNodes: StoreNodeItemType[] = nodes.map((item) => ({
nodeId: item.data.nodeId, nodeId: item.data.nodeId,
parentNodeId: item.data.parentNodeId, parentNodeId: item.data.parentNodeId,
...@@ -31,7 +45,14 @@ export const uiWorkflow2StoreWorkflow = ({ ...@@ -31,7 +45,14 @@ export const uiWorkflow2StoreWorkflow = ({
showStatus: item.data.showStatus, showStatus: item.data.showStatus,
position: item.position, position: item.position,
version: item.data.version, version: item.data.version,
inputs: item.data.inputs, inputs: filterUnselectableReferenceInputs({
node: item.data,
inputs: item.data.inputs,
edges,
chatConfig,
systemConfigNode,
getNodeById
}),
outputs: item.data.outputs, outputs: item.data.outputs,
isFolded: item.data.isFolded, isFolded: item.data.isFolded,
pluginId: item.data.pluginId, pluginId: item.data.pluginId,
...@@ -62,6 +83,73 @@ export const uiWorkflow2StoreWorkflow = ({ ...@@ -62,6 +83,73 @@ export const uiWorkflow2StoreWorkflow = ({
}; };
}; };
const emptyT = ((key: string) => key) as TFunction;
/**
* 保存时仅持久化当前引用选择器仍能选中的引用项。
* 已删除来源、已删除输出、类型不再匹配的引用在 UI 上不会展示标签,也不应继续写入 JSON。
*/
const filterUnselectableReferenceInputs = ({
node,
inputs,
edges,
chatConfig,
systemConfigNode,
getNodeById
}: {
node: FlowNodeItemType;
inputs: FlowNodeInputItemType[];
edges: Edge<any>[];
chatConfig?: AppChatConfigType;
systemConfigNode?: FlowNodeItemType;
getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined;
}) => {
return inputs.map((input) => {
if (!nodeInputIsReference(input)) return input;
const sourceNodes = getNodeAllSource({
nodeId: node.nodeId,
systemConfigNode,
getNodeById,
edges,
chatConfig: chatConfig ?? ({} as AppChatConfigType),
t: emptyT
});
const value = input.value as ReferenceValueType | undefined;
if (!Array.isArray(value)) return input;
if (typeof value[0] === 'string') {
const keepValue = workflowReferenceValueIsSelectable({
value,
sourceNodes,
valueType: input.valueType
});
return keepValue
? input
: {
...input,
value: undefined
};
}
const filteredValue = (value as ReferenceItemValueType[]).filter((item) =>
workflowReferenceValueIsSelectable({
value: item,
sourceNodes,
valueType: input.valueType
})
);
if (filteredValue.length === value.length) return input;
return {
...input,
value: filteredValue
};
});
};
export const filterExportModules = (modules: StoreNodeItemType[]) => { export const filterExportModules = (modules: StoreNodeItemType[]) => {
modules.forEach((module) => { modules.forEach((module) => {
// dataset - remove select dataset value // dataset - remove select dataset value
......
...@@ -409,14 +409,6 @@ const isUnsetReferenceValue = (value: unknown) => { ...@@ -409,14 +409,6 @@ const isUnsetReferenceValue = (value: unknown) => {
return false; return false;
}; };
/** 引用曾经有效配置过,但目标节点或输出已不存在(如上游节点被删除)。 */
const isStaleReferenceValue = (value: unknown, context: WorkflowCheckContext) => {
if (!isValidReferenceValueFormat(value)) return false;
const [refNodeId, refOutputId] = value;
if (!refNodeId || !refOutputId) return false;
return !referenceValueIsLive(value as ReferenceItemValueType, context);
};
const isEmptyReferenceInputValue = (value: unknown, isArrayType: boolean) => { const isEmptyReferenceInputValue = (value: unknown, isArrayType: boolean) => {
if (isArrayType) { if (isArrayType) {
return !Array.isArray(value) || value.length === 0; return !Array.isArray(value) || value.length === 0;
...@@ -794,33 +786,6 @@ export const checkWorkflowNodeIssues = ({ ...@@ -794,33 +786,6 @@ export const checkWorkflowNodeIssues = ({
inputKey: input.key inputKey: input.key
}); });
} }
if (isReferenceInput) {
if (isArrayReference) {
const value = Array.isArray(input.value) ? input.value : [];
const hasStaleReference = value.some((item) => isStaleReferenceValue(item, context));
if (hasStaleReference) {
addIssue({
node,
code: 'invalid_reference',
message: getWorkflowCheckIssueMessage('invalid_reference', t, {
inputName: getInputLabel(input, t)
}),
inputKey: input.key
});
}
} else if (isStaleReferenceValue(input.value, context)) {
addIssue({
node,
code: 'invalid_reference',
message: getWorkflowCheckIssueMessage('invalid_reference', t, {
inputName: getInputLabel(input, t)
}),
inputKey: input.key
});
}
}
}); });
} }
......
...@@ -46,6 +46,7 @@ import { ...@@ -46,6 +46,7 @@ import {
DatasetConcatModule, DatasetConcatModule,
getOneQuoteInputTemplate getOneQuoteInputTemplate
} from '@fastgpt/global/core/workflow/template/system/datasetConcat'; } from '@fastgpt/global/core/workflow/template/system/datasetConcat';
import { uiWorkflow2StoreWorkflow } from '@/pageComponents/app/detail/WorkflowComponents/utils';
import { HttpNode468 } from '@fastgpt/global/core/workflow/template/system/http468'; import { HttpNode468 } from '@fastgpt/global/core/workflow/template/system/http468';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart'; import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiChat'; import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiChat';
...@@ -277,7 +278,7 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -277,7 +278,7 @@ describe('checkWorkflowNodeIssues', () => {
expect(result.orphan.map((issue) => issue.code)).toContain('no_upstream'); expect(result.orphan.map((issue) => issue.code)).toContain('no_upstream');
}); });
it('reports invalid references', () => { it('does not report unselectable references', () => {
const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { const node = makeNode('ref', FlowNodeTypeEnum.answerNode, {
inputs: [ inputs: [
{ {
...@@ -295,7 +296,77 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -295,7 +296,77 @@ describe('checkWorkflowNodeIssues', () => {
edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }]
}); });
expect(result.ref.map((issue) => issue.code)).toContain('invalid_reference'); expect(result.ref?.map((issue) => issue.code) ?? []).not.toContain('invalid_reference');
});
it('filters unselectable single and multiple references when storing workflow data', () => {
const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, {
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.string
},
{
id: 'files',
key: 'files',
label: 'files',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.arrayString
},
{
id: 'count',
key: 'count',
label: 'count',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
]
});
const node = makeNode('ref', FlowNodeTypeEnum.chatNode, {
inputs: [
{
key: NodeInputKeyEnum.userChatInput,
label: '用户问题',
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: ['source', 'count']
},
{
key: NodeInputKeyEnum.fileUrlList,
label: '文件链接',
valueType: WorkflowIOValueTypeEnum.arrayString,
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: [
['source', 'files'],
['source', 'deleted'],
['source', 'count']
]
}
]
});
const result = uiWorkflow2StoreWorkflow({
nodes: [sourceNode, node],
edges: [{ id: 'e1', source: 'source', target: 'ref', type: EDGE_TYPE }],
chatConfig: {}
});
const storedNode = result.nodes.find((item) => item.nodeId === 'ref');
expect(
storedNode?.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput)
).toEqual(
expect.objectContaining({
value: undefined
})
);
expect(storedNode?.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)).toEqual(
expect.objectContaining({
value: [['source', 'files']]
})
);
}); });
it('reports generic plugin load errors without telling users to delete the tool', () => { it('reports generic plugin load errors without telling users to delete the tool', () => {
...@@ -652,7 +723,7 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -652,7 +723,7 @@ describe('checkWorkflowNodeIssues', () => {
}); });
}); });
it('returns invalid reference message with input name', () => { it('does not return invalid reference message for unselectable references', () => {
const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { const node = makeNode('ref', FlowNodeTypeEnum.answerNode, {
inputs: [ inputs: [
{ {
...@@ -670,7 +741,7 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -670,7 +741,7 @@ describe('checkWorkflowNodeIssues', () => {
edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }]
}); });
expect(result.ref[0]?.message).toBe('answer 引用了无效变量,需删除'); expect(result.ref?.map((issue) => issue.code) ?? []).not.toContain('invalid_reference');
}); });
it('treats unset reference as required_input_empty instead of invalid_reference', () => { it('treats unset reference as required_input_empty instead of invalid_reference', () => {
...@@ -1235,7 +1306,239 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -1235,7 +1306,239 @@ describe('checkWorkflowNodeIssues', () => {
}); });
}); });
it('reports invalid_reference when referenced upstream node or output was deleted', () => { describe('imported workflow stale auto-filled references', () => {
const makeImportedConnectedNode = ({
nodeId,
template,
overrides
}: {
nodeId: string;
template: FlowNodeTemplateType;
overrides: Partial<Record<NodeInputKeyEnum, unknown>>;
}): Node<FlowNodeItemType> =>
makeNode(nodeId, template.flowNodeType, {
inputs: template.inputs.map((input) => ({
...input,
value: overrides[input.key as NodeInputKeyEnum] ?? input.value ?? input.defaultValue
})),
outputs: template.outputs
});
const getInput = (node: Node<FlowNodeItemType>, inputKey: NodeInputKeyEnum) => {
const input = node.data.inputs.find((item) => item.key === inputKey);
expect(input).toBeDefined();
return input!;
};
const getIssueCodes =
(nodeId: string, inputKey: NodeInputKeyEnum) =>
(result: ReturnType<typeof checkWorkflowNodeIssues>) =>
result[nodeId]?.filter((issue) => issue.inputKey === inputKey).map((issue) => issue.code) ??
[];
it('does not report imported tool call file link auto-fill when current workflow start has no userFiles output', () => {
const importedToolCall = makeImportedConnectedNode({
nodeId: 'tool-call',
template: ToolCallNode,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput],
[NodeInputKeyEnum.fileUrlList]: [['start', NodeOutputKeyEnum.userFiles]]
}
});
const fileLinkInput = getInput(importedToolCall, NodeInputKeyEnum.fileUrlList);
expect(fileLinkInput).toMatchObject({
key: NodeInputKeyEnum.fileUrlList,
label: 'app:workflow.user_file_input',
valueType: WorkflowIOValueTypeEnum.arrayString,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.input],
value: [['start', NodeOutputKeyEnum.userFiles]]
});
expect(fileLinkInput.required).toBeUndefined();
expect(fileLinkInput.selectedTypeIndex).toBeUndefined();
const result = checkWorkflowNodeIssues({
nodes: [startNode, importedToolCall],
edges: [{ id: 'e-start-tool', source: 'start', target: 'tool-call', type: EDGE_TYPE }]
});
expect(getInput(importedToolCall, NodeInputKeyEnum.fileUrlList).value).toEqual([
['start', NodeOutputKeyEnum.userFiles]
]);
expect(getIssueCodes('tool-call', NodeInputKeyEnum.fileUrlList)(result)).not.toContain(
'invalid_reference'
);
});
it.each([
['AI 对话', AiChatModule, 'ai-chat', NodeInputKeyEnum.fileUrlList],
['知识库搜索', DatasetSearchModule, 'dataset-search', NodeInputKeyEnum.datasetSearchInput]
] as const)(
'does not report imported %s stale file auto-fill as invalid reference',
(_nodeName, template, nodeId, inputKey) => {
const importedNode = makeImportedConnectedNode({
nodeId,
template,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput],
[NodeInputKeyEnum.datasetSearchInput]: [
['start', NodeOutputKeyEnum.userChatInput],
['start', NodeOutputKeyEnum.userFiles]
],
[NodeInputKeyEnum.fileUrlList]: [['start', NodeOutputKeyEnum.userFiles]]
}
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, importedNode],
edges: [{ id: `e-start-${nodeId}`, source: 'start', target: nodeId, type: EDGE_TYPE }]
});
if (inputKey === NodeInputKeyEnum.fileUrlList) {
expect(getInput(importedNode, inputKey).value).toEqual([
['start', NodeOutputKeyEnum.userFiles]
]);
} else {
expect(getInput(importedNode, inputKey).value).toEqual([
['start', NodeOutputKeyEnum.userChatInput],
['start', NodeOutputKeyEnum.userFiles]
]);
}
expect(getIssueCodes(nodeId, inputKey)(result)).not.toContain('invalid_reference');
}
);
it('manual add and manual re-select do not report the imported file link false positive', () => {
const manualToolCall = makeImportedConnectedNode({
nodeId: 'manual-tool-call',
template: ToolCallNode,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput]
}
});
manualToolCall.data.inputs = applyWorkflowStartInputAutoFill({
inputs: manualToolCall.data.inputs,
workflowStartNodeId: startNode.data.nodeId,
workflowStartOutputs: startNode.data.outputs
});
const reselectedToolCall = makeImportedConnectedNode({
nodeId: 'reselected-tool-call',
template: ToolCallNode,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput],
[NodeInputKeyEnum.fileUrlList]: undefined
}
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, manualToolCall, reselectedToolCall],
edges: [
{
id: 'e-start-manual',
source: 'start',
target: 'manual-tool-call',
type: EDGE_TYPE
},
{
id: 'e-start-reselected',
source: 'start',
target: 'reselected-tool-call',
type: EDGE_TYPE
}
]
});
expect(getIssueCodes('manual-tool-call', NodeInputKeyEnum.fileUrlList)(result)).not.toContain(
'invalid_reference'
);
expect(
getIssueCodes('reselected-tool-call', NodeInputKeyEnum.fileUrlList)(result)
).not.toContain('invalid_reference');
});
it('question classify imported user question keeps valid workflow start reference', () => {
const importedClassify = makeImportedConnectedNode({
nodeId: 'classify',
template: ClassifyQuestionModule,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput]
}
});
const userQuestionInput = getInput(importedClassify, NodeInputKeyEnum.userChatInput);
expect(userQuestionInput).toMatchObject({
key: NodeInputKeyEnum.userChatInput,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
required: true,
value: ['start', NodeOutputKeyEnum.userChatInput]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, importedClassify],
edges: [{ id: 'e-start-classify', source: 'start', target: 'classify', type: EDGE_TYPE }]
});
expect(getIssueCodes('classify', NodeInputKeyEnum.userChatInput)(result)).not.toContain(
'invalid_reference'
);
});
it('does not report truly invalid manual references', () => {
const invalidToolCall = makeImportedConnectedNode({
nodeId: 'invalid-tool-call',
template: ToolCallNode,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['deleted-node', NodeOutputKeyEnum.userChatInput]
}
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, invalidToolCall],
edges: [
{ id: 'e-start-invalid', source: 'start', target: 'invalid-tool-call', type: EDGE_TYPE }
]
});
expect(
getIssueCodes('invalid-tool-call', NodeInputKeyEnum.userChatInput)(result)
).not.toContain('invalid_reference');
});
it('does not report invalid references mixed into an imported file input', () => {
const invalidToolCall = makeImportedConnectedNode({
nodeId: 'mixed-invalid-tool-call',
template: ToolCallNode,
overrides: {
[NodeInputKeyEnum.userChatInput]: ['start', NodeOutputKeyEnum.userChatInput],
[NodeInputKeyEnum.fileUrlList]: [
['start', NodeOutputKeyEnum.userFiles],
['deleted-node', NodeOutputKeyEnum.userFiles]
]
}
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, invalidToolCall],
edges: [
{
id: 'e-start-mixed-invalid',
source: 'start',
target: 'mixed-invalid-tool-call',
type: EDGE_TYPE
}
]
});
expect(
getIssueCodes('mixed-invalid-tool-call', NodeInputKeyEnum.fileUrlList)(result)
).not.toContain('invalid_reference');
});
});
it('does not report invalid_reference when referenced upstream node or output was deleted', () => {
const nodeWithDeletedNodeRef = makeNode('deleted-node', FlowNodeTypeEnum.chatNode, { const nodeWithDeletedNodeRef = makeNode('deleted-node', FlowNodeTypeEnum.chatNode, {
inputs: [ inputs: [
{ {
...@@ -1271,10 +1574,16 @@ describe('checkWorkflowNodeIssues', () => { ...@@ -1271,10 +1574,16 @@ describe('checkWorkflowNodeIssues', () => {
] ]
}); });
expect(result['deleted-node'].map((issue) => issue.code)).toContain('invalid_reference'); expect(result['deleted-node']?.map((issue) => issue.code) ?? []).not.toContain(
expect(result['deleted-node'].map((issue) => issue.code)).not.toContain('required_input_empty'); 'invalid_reference'
expect(result['deleted-output'].map((issue) => issue.code)).toContain('invalid_reference'); );
expect(result['deleted-output'].map((issue) => issue.code)).not.toContain( expect(result['deleted-node']?.map((issue) => issue.code) ?? []).not.toContain(
'required_input_empty'
);
expect(result['deleted-output']?.map((issue) => issue.code) ?? []).not.toContain(
'invalid_reference'
);
expect(result['deleted-output']?.map((issue) => issue.code) ?? []).not.toContain(
'required_input_empty' 'required_input_empty'
); );
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment