Commit 43ee4ec9 by siigure Committed by GitHub

feat(workflow): add workflow check issue handling and validation mess… (#7167)

* feat(workflow): add workflow check issue handling and validation messages

- Introduced new schemas for workflow check issues, including levels and detailed error messages.
- Enhanced the NodeTemplateListTypeSchema to include optional workflow check issues.
- Updated various components to display validation messages for required inputs, invalid references, and tool statuses.
- Implemented context methods for synchronizing and refreshing workflow check issues.
- Added UI elements to visually represent workflow check issues in the node cards.

This update improves user feedback during workflow configuration and enhances error handling capabilities.

* chore: trigger workflows

* fix(workflow): show required input hints for newly added nodes

* feat(workflow): auto-fill input refs when connecting from workflow start

* fix workflow start auto-fill on downstream edge changes

* refactor(workflow): update workflow validation checks and error handling

- Renamed `checkWorkflowNodeAndConnection` to `checkWorkflowBeforeRunOrPublish` for clarity.
- Enhanced error handling to return structured results, including specific error node IDs.
- Updated related components to utilize the new validation checks and improve user feedback during workflow configuration.
- Added internationalization support for workflow check messages.

* feat(workflow): add error message for tool load failures in internationalization

* feat(workflow): implement output auto-fill revert patches for workflow start nodes

* refactor(workflow): reorganize workflow utility imports and enhance structure

* feat(workflow): add checkPendingImprove icon and update NodeCard component
parent eff2963a
...@@ -27,7 +27,8 @@ export const getOneQuoteInputTemplate = ({ ...@@ -27,7 +27,8 @@ export const getOneQuoteInputTemplate = ({
label: `${i18nT('workflow:quote_num')}-${index}`, label: `${i18nT('workflow:quote_num')}-${index}`,
debugLabel: i18nT('workflow:knowledge_base_reference'), debugLabel: i18nT('workflow:knowledge_base_reference'),
canEdit: true, canEdit: true,
valueType: WorkflowIOValueTypeEnum.datasetQuote valueType: WorkflowIOValueTypeEnum.datasetQuote,
required: true
}); });
export const DatasetConcatModule: FlowNodeTemplateType = { export const DatasetConcatModule: FlowNodeTemplateType = {
......
...@@ -66,7 +66,7 @@ export const HttpNode468: FlowNodeTemplateType = { ...@@ -66,7 +66,7 @@ export const HttpNode468: FlowNodeTemplateType = {
label: '', label: '',
description: i18nT('common:core.module.input.description.Http Request Url'), description: i18nT('common:core.module.input.description.Http Request Url'),
placeholder: 'https://api.ai.com/getInventory', placeholder: 'https://api.ai.com/getInventory',
required: false required: true
}, },
{ {
key: NodeInputKeyEnum.headerSecret, key: NodeInputKeyEnum.headerSecret,
......
...@@ -264,11 +264,27 @@ export const NodeTemplateListTypeSchema = z.array( ...@@ -264,11 +264,27 @@ export const NodeTemplateListTypeSchema = z.array(
); );
export type NodeTemplateListType = z.infer<typeof NodeTemplateListTypeSchema>; export type NodeTemplateListType = z.infer<typeof NodeTemplateListTypeSchema>;
export const WorkflowCheckIssueLevelSchema = z.enum(['error', 'warning']);
export type WorkflowCheckIssueLevel = z.infer<typeof WorkflowCheckIssueLevelSchema>;
export const WorkflowCheckIssueSchema = z.object({
nodeId: z.string(),
nodeName: z.string().optional(),
nodeType: z.enum(FlowNodeTypeEnum),
level: WorkflowCheckIssueLevelSchema,
code: z.string(),
message: z.string(),
inputKey: z.string().optional()
});
export type WorkflowCheckIssue = z.infer<typeof WorkflowCheckIssueSchema>;
export type WorkflowCheckNodeIssueMap = Record<string, WorkflowCheckIssue[]>;
// react flow node type // react flow node type
export const FlowNodeItemSchema = FlowNodeTemplateTypeSchema.extend({ export const FlowNodeItemSchema = FlowNodeTemplateTypeSchema.extend({
nodeId: z.string(), nodeId: z.string(),
parentNodeId: z.string().optional(), parentNodeId: z.string().optional(),
isError: BoolSchema.optional(), isError: BoolSchema.optional(),
workflowCheckIssues: z.array(WorkflowCheckIssueSchema).optional(),
searchedText: z.string().optional(), searchedText: z.string().optional(),
debugResult: z debugResult: z
.object({ .object({
......
...@@ -178,6 +178,8 @@ export const iconPaths = { ...@@ -178,6 +178,8 @@ export const iconPaths = {
'core/app/type/workflow': () => import('./icons/core/app/type/workflow.svg'), 'core/app/type/workflow': () => import('./icons/core/app/type/workflow.svg'),
'core/app/type/workflowFill': () => import('./icons/core/app/type/workflowFill.svg'), 'core/app/type/workflowFill': () => import('./icons/core/app/type/workflowFill.svg'),
'core/app/variable/input': () => import('./icons/core/app/variable/input.svg'), 'core/app/variable/input': () => import('./icons/core/app/variable/input.svg'),
'core/app/workflow/checkPendingImprove': () =>
import('./icons/core/app/workflow/checkPendingImprove.svg'),
'core/chat/QGFill': () => import('./icons/core/chat/QGFill.svg'), 'core/chat/QGFill': () => import('./icons/core/chat/QGFill.svg'),
'core/chat/backText': () => import('./icons/core/chat/backText.svg'), 'core/chat/backText': () => import('./icons/core/chat/backText.svg'),
'core/chat/chatFill': () => import('./icons/core/chat/chatFill.svg'), 'core/chat/chatFill': () => import('./icons/core/chat/chatFill.svg'),
......
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none">
<path
d="M10.2902 3.16394C11.4197 2.94535 12.5807 2.94535 13.7102 3.16394M13.7102 20.8361C12.5807 21.0546 11.4197 21.0546 10.2902 20.8361M17.048 4.54903C18.0033 5.19632 18.8252 6.02128 19.469 6.97897M3.16394 13.71C2.94535 12.5805 2.94535 11.4196 3.16394 10.2901M19.4512 17.0479C18.8039 18.0032 17.9789 18.8251 17.0212 19.4688M20.8361 10.2901C21.0546 11.4196 21.0546 12.5805 20.8361 13.71M4.54905 6.95195C5.19634 5.99665 6.02131 5.17475 6.97902 4.53102M6.952 19.451C5.99669 18.8037 5.17478 17.9787 4.53103 17.021"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
...@@ -650,6 +650,26 @@ ...@@ -650,6 +650,26 @@
"core.plugin.Get Plugin Module Detail Failed": "Failed to Retrieve Plugin Information", "core.plugin.Get Plugin Module Detail Failed": "Failed to Retrieve Plugin Information",
"core.tip.leave page": "Content has been modified, confirm to leave the page?", "core.tip.leave page": "Content has been modified, confirm to leave the page?",
"core.workflow.Can not delete node": "This Node Cannot Be Deleted", "core.workflow.Can not delete node": "This Node Cannot Be Deleted",
"core.workflow.check.status.pending_handle": "To handle",
"core.workflow.check.status.pending_improve": "To complete",
"core.workflow.check.required_input_empty": "Please fill in required field {{inputName}}",
"core.workflow.check.no_upstream": "Not connected to other nodes",
"core.workflow.check.invalid_reference": "{{inputName}} references an invalid variable, please delete",
"core.workflow.check.if_else_incomplete": "Incomplete condition configuration, please complete",
"core.workflow.check.user_select_empty": "Configure at least one option",
"core.workflow.check.user_select_value_empty": "Option cannot be empty",
"core.workflow.check.form_input_empty": "Configure at least one field",
"core.workflow.check.classify_question_empty": "Configure at least one category",
"core.workflow.check.classify_question_value_empty": "Category value cannot be empty",
"core.workflow.check.code_input_incomplete": "Incomplete input variable configuration, please complete",
"core.workflow.check.http_url_empty": "Configure request URL",
"core.workflow.check.context_extract_empty": "Configure at least one target field",
"core.workflow.check.tool_call_empty": "Configure a tool or enable virtual machine",
"core.workflow.check.tool_inactive": "This tool is not activated yet, please activate it",
"core.workflow.check.tool_missing": "This tool does not exist, please delete it",
"core.workflow.check.tool_load_failed": "Failed to load this tool, please try again later",
"core.workflow.check.tool_no_permission": "Current account has no permission to access this resource",
"core.workflow.check.tool_offline": "This tool has been deactivated, please delete it",
"core.workflow.Check Failed": "Workflow verification failed, please check whether the value is missing, and whether the connection is normal.", "core.workflow.Check Failed": "Workflow verification failed, please check whether the value is missing, and whether the connection is normal.",
"core.workflow.Confirm stop debug": "Confirm to Stop Debugging? Debug Information Will Not Be Retained.", "core.workflow.Confirm stop debug": "Confirm to Stop Debugging? Debug Information Will Not Be Retained.",
"core.workflow.Copy node": "Node Copied", "core.workflow.Copy node": "Node Copied",
......
...@@ -650,6 +650,26 @@ ...@@ -650,6 +650,26 @@
"core.plugin.Get Plugin Module Detail Failed": "加载插件异常", "core.plugin.Get Plugin Module Detail Failed": "加载插件异常",
"core.tip.leave page": "内容已修改,确认离开页面吗?", "core.tip.leave page": "内容已修改,确认离开页面吗?",
"core.workflow.Can not delete node": "该节点不允许删除", "core.workflow.Can not delete node": "该节点不允许删除",
"core.workflow.check.status.pending_handle": "待处理",
"core.workflow.check.status.pending_improve": "待完善",
"core.workflow.check.required_input_empty": "需填写必填项 {{inputName}}",
"core.workflow.check.no_upstream": "未与其他节点连线",
"core.workflow.check.invalid_reference": "{{inputName}} 引用了无效变量,需删除",
"core.workflow.check.if_else_incomplete": "存在未完成的条件配置,请完善",
"core.workflow.check.user_select_empty": "需配置至少一个选项",
"core.workflow.check.user_select_value_empty": "选项不可为空",
"core.workflow.check.form_input_empty": "需配置至少一个字段",
"core.workflow.check.classify_question_empty": "需配置至少一个分类",
"core.workflow.check.classify_question_value_empty": "分类值不可为空",
"core.workflow.check.code_input_incomplete": "存在未完成的输入变量配置,请完善",
"core.workflow.check.http_url_empty": "需配置请求地址",
"core.workflow.check.context_extract_empty": "需配置至少一个目标字段",
"core.workflow.check.tool_call_empty": "需配置工具或开启虚拟机",
"core.workflow.check.tool_inactive": "该工具尚未激活,请激活使用",
"core.workflow.check.tool_missing": "该工具不存在,请删除",
"core.workflow.check.tool_load_failed": "工具加载失败,请稍后重试",
"core.workflow.check.tool_no_permission": "当前账号无权限访问该资源",
"core.workflow.check.tool_offline": "该工具已停用,请删除",
"core.workflow.Check Failed": "工作流校验失败,请检查是否缺失、缺值,连线是否正常", "core.workflow.Check Failed": "工作流校验失败,请检查是否缺失、缺值,连线是否正常",
"core.workflow.Confirm stop debug": "确认终止调试?调试信息将会不保留。", "core.workflow.Confirm stop debug": "确认终止调试?调试信息将会不保留。",
"core.workflow.Copy node": "已复制节点", "core.workflow.Copy node": "已复制节点",
......
...@@ -645,6 +645,26 @@ ...@@ -645,6 +645,26 @@
"core.plugin.Get Plugin Module Detail Failed": "取得外掛程式資訊失敗", "core.plugin.Get Plugin Module Detail Failed": "取得外掛程式資訊失敗",
"core.tip.leave page": "內容已修改,確認離開頁面嗎?", "core.tip.leave page": "內容已修改,確認離開頁面嗎?",
"core.workflow.Can not delete node": "此節點不允許刪除", "core.workflow.Can not delete node": "此節點不允許刪除",
"core.workflow.check.status.pending_handle": "待處理",
"core.workflow.check.status.pending_improve": "待完善",
"core.workflow.check.required_input_empty": "需填寫必填項 {{inputName}}",
"core.workflow.check.no_upstream": "未與其他節點連線",
"core.workflow.check.invalid_reference": "{{inputName}} 引用了無效變量,需刪除",
"core.workflow.check.if_else_incomplete": "存在未完成的條件配置,請完善",
"core.workflow.check.user_select_empty": "需配置至少一個選項",
"core.workflow.check.user_select_value_empty": "選項不可為空",
"core.workflow.check.form_input_empty": "需配置至少一個字段",
"core.workflow.check.classify_question_empty": "需配置至少一個分類",
"core.workflow.check.classify_question_value_empty": "分類值不可為空",
"core.workflow.check.code_input_incomplete": "存在未完成的輸入變量配置,請完善",
"core.workflow.check.http_url_empty": "需配置請求地址",
"core.workflow.check.context_extract_empty": "需配置至少一個目標字段",
"core.workflow.check.tool_call_empty": "需配置工具或開啟虛擬機",
"core.workflow.check.tool_inactive": "該工具尚未激活,請激活使用",
"core.workflow.check.tool_missing": "該工具不存在,請刪除",
"core.workflow.check.tool_load_failed": "工具載入失敗,請稍後重試",
"core.workflow.check.tool_no_permission": "當前帳號無權限存取該資源",
"core.workflow.check.tool_offline": "該工具已停用,請刪除",
"core.workflow.Check Failed": "工作流校驗失敗,請檢查是否遺失、缺值,連線是否正常", "core.workflow.Check Failed": "工作流校驗失敗,請檢查是否遺失、缺值,連線是否正常",
"core.workflow.Confirm stop debug": "確認停止除錯?除錯資訊將不會保留。", "core.workflow.Confirm stop debug": "確認停止除錯?除錯資訊將不會保留。",
"core.workflow.Copy node": "已複製節點", "core.workflow.Copy node": "已複製節點",
......
...@@ -28,11 +28,8 @@ import type { AppVersionSchemaType } from '@fastgpt/global/core/app/version/type ...@@ -28,11 +28,8 @@ import type { AppVersionSchemaType } from '@fastgpt/global/core/app/version/type
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload'; import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { isProduction } from '@fastgpt/global/common/system/constants'; import { isProduction } from '@fastgpt/global/common/system/constants';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { import { storeEdge2RenderEdge, storeNode2FlowNode } from '@/web/core/workflow/utils';
checkWorkflowNodeAndConnection, import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck';
storeEdge2RenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
import type { AppForm2WorkflowFnType, Form2WorkflowFnType } from './type'; import type { AppForm2WorkflowFnType, Form2WorkflowFnType } from './type';
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { useUserStore } from '@/web/support/user/useUserStore'; import { useUserStore } from '@/web/support/user/useUserStore';
...@@ -290,15 +287,15 @@ const Header = ({ ...@@ -290,15 +287,15 @@ const Header = ({
const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t })); const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t }));
const edges = storeEdges.map((item) => storeEdge2RenderEdge({ edge: item })); const edges = storeEdges.map((item) => storeEdge2RenderEdge({ edge: item }));
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges }); const checkResults = checkWorkflowBeforeRunOrPublish({ nodes, edges, t });
if (checkResults) { if (checkResults.hasError) {
toast({ toast({
title: t('app:app.error.publish_unExist_app'), title: t('app:app.error.publish_unExist_app'),
status: 'warning' status: 'warning'
}); });
} }
return !checkResults; return !checkResults.hasError;
}} }}
/> />
</> </>
......
...@@ -7,9 +7,9 @@ import { Box } from '@chakra-ui/react'; ...@@ -7,9 +7,9 @@ import { Box } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import React from 'react'; import React from 'react';
import { XYPosition } from 'reactflow';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../context/workflowInitContext'; import { WorkflowBufferDataContext } from '../context/workflowInitContext';
import { WorkflowActionsContext } from '../context/workflowActionsContext';
type ModuleTemplateListProps = { type ModuleTemplateListProps = {
isOpen: boolean; isOpen: boolean;
...@@ -20,6 +20,10 @@ export const sliderWidth = 460; ...@@ -20,6 +20,10 @@ export const sliderWidth = 460;
const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes); const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
const onRefreshSingleNodeWorkflowCheckIssues = useContextSelector(
WorkflowActionsContext,
(v) => v.onRefreshSingleNodeWorkflowCheckIssues
);
const { const {
templateType, templateType,
...@@ -47,6 +51,11 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { ...@@ -47,6 +51,11 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
.concat(newNodes); .concat(newNodes);
return newState; return newState;
}); });
// 新增节点后立即同步下方待完善提示,不依赖 10s 定时扫描或用户首次编辑。
setTimeout(() => {
onRefreshSingleNodeWorkflowCheckIssues(newNodes[0]?.data.nodeId ?? '');
}, 0);
}); });
return ( return (
......
import MyBox from '@fastgpt/web/components/common/MyBox'; import { collectWorkflowStartInputAutoFillPatches } from '@/web/core/workflow/workflowStartAutoFill';
import React from 'react'; import { Popover, PopoverBody, PopoverContent } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { import {
EDGE_TYPE, EDGE_TYPE,
FlowNodeTypeEnum, FlowNodeTypeEnum,
isNestedChildSystemNodeType isNestedChildSystemNodeType
} from '@fastgpt/global/core/workflow/node/constant'; } from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type Node } from 'reactflow'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { WorkflowBufferDataContext } from '../context/workflowInitContext';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import React from 'react';
import { type Node } from 'reactflow';
import { useContextSelector } from 'use-context-selector';
import { WorkflowActionsContext } from '../context/workflowActionsContext';
import { WorkflowBufferDataContext, WorkflowInitContext } from '../context/workflowInitContext';
import { WorkflowModalContext } from '../context/workflowModalContext';
import NodeTemplateListHeader from './components/NodeTemplates/header'; import NodeTemplateListHeader from './components/NodeTemplates/header';
import NodeTemplateList from './components/NodeTemplates/list'; import NodeTemplateList from './components/NodeTemplates/list';
import { Popover, PopoverContent, PopoverBody } from '@chakra-ui/react';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates'; import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { popoverHeight, popoverWidth } from './hooks/useWorkflow'; import { popoverHeight, popoverWidth } from './hooks/useWorkflow';
import { WorkflowModalContext } from '../context/workflowModalContext';
const NodeTemplatesPopover = () => { const NodeTemplatesPopover = () => {
const { handleParams, setHandleParams } = useContextSelector(WorkflowModalContext, (v) => v); const { handleParams, setHandleParams } = useContextSelector(WorkflowModalContext, (v) => v);
const { setNodes, setEdges } = useContextSelector(WorkflowBufferDataContext, (v) => v); const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const { edges, setNodes, setEdges, workflowStartNode } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const onRefreshSingleNodeWorkflowCheckIssues = useContextSelector(
WorkflowActionsContext,
(v) => v.onRefreshSingleNodeWorkflowCheckIssues
);
const { const {
templateType, templateType,
...@@ -87,7 +98,25 @@ const NodeTemplatesPopover = () => { ...@@ -87,7 +98,25 @@ const NodeTemplatesPopover = () => {
return newState; return newState;
}); });
if (workflowStartNode) {
const patches = collectWorkflowStartInputAutoFillPatches({
nodes: nodes.concat(newNodes),
edges: edges.concat(newEdges),
workflowStartNode
});
if (patches.length > 0) {
onChangeNode(patches.map((patch) => ({ ...patch, type: 'updateInput' as const })));
}
}
setHandleParams(null); setHandleParams(null);
setTimeout(() => {
newNodes.forEach((node) => {
onRefreshSingleNodeWorkflowCheckIssues(node.data.nodeId);
});
}, 0);
}); });
if (!handleParams) return null; if (!handleParams) return null;
......
...@@ -42,8 +42,9 @@ import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/ ...@@ -42,8 +42,9 @@ import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/
import { LoopRunStartNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunStart'; import { LoopRunStartNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunStart';
import { useReactFlow } from 'reactflow'; import { useReactFlow } from 'reactflow';
import type { Node } from 'reactflow'; import type { Node } from 'reactflow';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils'; import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { applyWorkflowStartInputAutoFill } from '@/web/core/workflow/workflowStartAutoFill';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
...@@ -235,7 +236,7 @@ const NodeTemplateList = ({ ...@@ -235,7 +236,7 @@ const NodeTemplateList = ({
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const { computedNewNodeName } = useWorkflowUtils(); const { computedNewNodeName } = useWorkflowUtils();
const { getNodeList, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams); const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams);
const { getIntersectingNodes } = useReactFlow(); const { getIntersectingNodes } = useReactFlow();
...@@ -288,28 +289,6 @@ const NodeTemplateList = ({ ...@@ -288,28 +289,6 @@ const NodeTemplateList = ({
} }
})(); })();
const defaultValueMap: Record<string, any> = {
[NodeInputKeyEnum.userChatInput]: undefined,
[NodeInputKeyEnum.datasetSearchInput]: undefined,
[NodeInputKeyEnum.fileUrlList]: undefined
};
getNodeList().forEach((node) => {
if (node.flowNodeType === FlowNodeTypeEnum.workflowStart) {
defaultValueMap[NodeInputKeyEnum.userChatInput] = [
node.nodeId,
NodeOutputKeyEnum.userChatInput
];
defaultValueMap[NodeInputKeyEnum.fileUrlList] = [
[node.nodeId, NodeOutputKeyEnum.userFiles]
];
defaultValueMap[NodeInputKeyEnum.datasetSearchInput] = [
[node.nodeId, NodeOutputKeyEnum.userChatInput],
[node.nodeId, NodeOutputKeyEnum.userFiles]
];
}
});
const currentNode = getNodeById(handleParams?.nodeId); const currentNode = getNodeById(handleParams?.nodeId);
// Popover insertion inherits the source node's parent; a dragged // Popover insertion inherits the source node's parent; a dragged
...@@ -359,29 +338,43 @@ const NodeTemplateList = ({ ...@@ -359,29 +338,43 @@ const NodeTemplateList = ({
} }
} }
const newNode = nodeTemplate2FlowNode({ const preparedInputs = templateNode.inputs
template: {
...templateNode,
inputs: templateNode.inputs
.filter((input) => input.deprecated !== true) .filter((input) => input.deprecated !== true)
.map((input) => ({ .map((input) => ({
...input, ...input,
value: defaultValueMap[input.key] ?? input.value ?? input.defaultValue, value: input.value ?? input.defaultValue,
valueDesc: input.valueDesc ? t(input.valueDesc as any) : undefined, valueDesc: input.valueDesc ? t(input.valueDesc as any) : undefined,
label: t(input.label as any), label: t(input.label as any),
description: input.description ? t(input.description as any) : undefined, description: input.description ? t(input.description as any) : undefined,
placeholder: input.placeholder ? t(input.placeholder as any) : undefined, placeholder: input.placeholder ? t(input.placeholder as any) : undefined,
debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined, debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined,
toolDescription: input.toolDescription toolDescription: input.toolDescription ? t(input.toolDescription as any) : undefined,
? t(input.toolDescription as any)
: undefined,
list: Array.isArray(input.list) list: Array.isArray(input.list)
? input.list.map((opt: any) => ({ ? input.list.map((opt: any) => ({
...opt, ...opt,
label: opt?.label ? t(opt.label as any) : opt?.label label: opt?.label ? t(opt.label as any) : opt?.label
})) }))
: input.list : input.list
})), }));
const inputsWithAutoFill =
currentNode?.flowNodeType === FlowNodeTypeEnum.workflowStart
? applyWorkflowStartInputAutoFill({
inputs: preparedInputs,
workflowStartNodeId: currentNode.nodeId,
workflowStartOutputs: currentNode.outputs
})
: preparedInputs;
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
name: computedNewNodeName({
templateName: t(templateNode.name as any),
flowNodeType: templateNode.flowNodeType,
pluginId: templateNode.pluginId
}),
intro: t(templateNode.intro as any),
inputs: inputsWithAutoFill,
outputs: templateNode.outputs outputs: templateNode.outputs
.filter((output) => output.deprecated !== true) .filter((output) => output.deprecated !== true)
.map((output) => ({ .map((output) => ({
...@@ -441,16 +434,7 @@ const NodeTemplateList = ({ ...@@ -441,16 +434,7 @@ const NodeTemplateList = ({
console.error('Failed to create node template:', error); console.error('Failed to create node template:', error);
} }
}, },
[ [computedNewNodeName, getNodeById, handleParams, getIntersectingNodes, onAddNode, t, toast]
computedNewNodeName,
getNodeById,
handleParams,
getNodeList,
getIntersectingNodes,
onAddNode,
t,
toast
]
); );
const formatTemplatesArrayData = useMemo(() => { const formatTemplatesArrayData = useMemo(() => {
......
...@@ -5,7 +5,9 @@ import { ...@@ -5,7 +5,9 @@ import {
type StoreEdgeItemType type StoreEdgeItemType
} from '@fastgpt/global/core/workflow/type/edge'; } from '@fastgpt/global/core/workflow/type/edge';
import { useCallback, useState, useMemo } from 'react'; import { useCallback, useState, useMemo } from 'react';
import { checkWorkflowNodeAndConnection, getNodeAllSource } from '@/web/core/workflow/utils'; import { useReactFlow } from 'reactflow';
import { getNodeAllSource } from '@/web/core/workflow/utils';
import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { uiWorkflow2StoreWorkflow } from '../../utils'; import { uiWorkflow2StoreWorkflow } from '../../utils';
import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
...@@ -58,7 +60,11 @@ export const useDebug = () => { ...@@ -58,7 +60,11 @@ export const useDebug = () => {
WorkflowBufferDataContext, WorkflowBufferDataContext,
(v) => v.childrenNodeIdListMap (v) => v.childrenNodeIdListMap
); );
const { onUpdateNodeError, onRemoveError } = useContextSelector(WorkflowActionsContext, (v) => v); const { fitView } = useReactFlow();
const { onUpdateNodeError, onRemoveError, onSyncWorkflowCheckIssues } = useContextSelector(
WorkflowActionsContext,
(v) => v
);
const onStartNodeDebug = useContextSelector(WorkflowDebugContext, (v) => v.onStartNodeDebug); const onStartNodeDebug = useContextSelector(WorkflowDebugContext, (v) => v.onStartNodeDebug);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail); const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
...@@ -94,22 +100,48 @@ export const useDebug = () => { ...@@ -94,22 +100,48 @@ export const useDebug = () => {
const flowData2StoreDataAndCheck = useCallback(async () => { const flowData2StoreDataAndCheck = useCallback(async () => {
const nodes = getNodes(); const nodes = getNodes();
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges }); const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({
if (!checkResults) { nodes,
edges,
t: workflowT
});
if (!hasError) {
onRemoveError(); onRemoveError();
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges }); const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges });
return JSON.stringify(storeNodes); return JSON.stringify(storeNodes);
} else { }
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
onSyncWorkflowCheckIssues(issueMap);
if (firstErrorNodeId) {
onUpdateNodeError(firstErrorNodeId, true);
const firstErrorNode = nodes.find((node) => node.data.nodeId === firstErrorNodeId);
if (firstErrorNode) {
fitView({
nodes: [firstErrorNode],
padding: 0.3
});
}
}
toast({ toast({
status: 'warning', status: 'warning',
title: t('common:core.workflow.Check Failed') title: t('common:core.workflow.Check Failed')
}); });
return Promise.reject(); return Promise.reject();
} }, [
}, [edges, getNodes, onRemoveError, onUpdateNodeError, t, toast]); edges,
fitView,
getNodes,
onRemoveError,
onSyncWorkflowCheckIssues,
onUpdateNodeError,
t,
toast,
workflowT
]);
const openDebugNode = useCallback( const openDebugNode = useCallback(
async ({ entryNodeId }: { entryNodeId: string }) => { async ({ entryNodeId }: { entryNodeId: string }) => {
......
...@@ -26,7 +26,8 @@ import { useTranslation } from 'next-i18next'; ...@@ -26,7 +26,8 @@ import { useTranslation } from 'next-i18next';
import { useKeyboard } from './useKeyboard'; import { useKeyboard } from './useKeyboard';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { type THelperLine } from '@/web/core/workflow/type'; import { type THelperLine } from '@/web/core/workflow/type';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { collectWorkflowStartInputAutoFillPatches } from '@/web/core/workflow/workflowStartAutoFill';
import { useDebounceEffect, useMemoizedFn } from 'ahooks'; import { useDebounceEffect, useMemoizedFn } from 'ahooks';
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { import {
...@@ -469,7 +470,10 @@ export const useWorkflow = () => { ...@@ -469,7 +470,10 @@ export const useWorkflow = () => {
} = useContextSelector(WorkflowBufferDataContext, (state) => state); } = useContextSelector(WorkflowBufferDataContext, (state) => state);
const selectedNodesMap = useContextSelector(WorkflowNodeDataContext, (v) => v.selectedNodesMap); const selectedNodesMap = useContextSelector(WorkflowNodeDataContext, (v) => v.selectedNodesMap);
const { setConnectingEdge, onChangeNode } = useContextSelector(WorkflowActionsContext, (v) => v); const { setConnectingEdge, onChangeNode, onUpdateNodeError } = useContextSelector(
WorkflowActionsContext,
(v) => v
);
const pushPastSnapshot = useContextSelector(WorkflowSnapshotContext, (v) => v.pushPastSnapshot); const pushPastSnapshot = useContextSelector(WorkflowSnapshotContext, (v) => v.pushPastSnapshot);
const { setHoverEdgeId, setMenu } = useContextSelector(WorkflowUIContext, (v) => v); const { setHoverEdgeId, setMenu } = useContextSelector(WorkflowUIContext, (v) => v);
...@@ -662,8 +666,16 @@ export const useWorkflow = () => { ...@@ -662,8 +666,16 @@ export const useWorkflow = () => {
change.selected = true; change.selected = true;
} }
// 错误节点失焦(取消选中)时清除标红,与原版点击节点取消标红行为一致。
if (!change.selected) {
const node = getRawNodeById(change.id);
if (node?.data.isError) {
onUpdateNodeError(node.data.nodeId, false);
}
return;
}
// 父子互斥(后操作优先): 选父则取消其已选 children;选子则取消已选父。 // 父子互斥(后操作优先): 选父则取消其已选 children;选子则取消已选父。
if (!change.selected) return;
const node = getRawNodeById(change.id); const node = getRawNodeById(change.id);
if (!node) return; if (!node) return;
...@@ -912,6 +924,14 @@ export const useWorkflow = () => { ...@@ -912,6 +924,14 @@ export const useWorkflow = () => {
}, [setConnectingEdge]); }, [setConnectingEdge]);
const onConnect = useCallback( const onConnect = useCallback(
({ connect }: { connect: Connection }) => { ({ connect }: { connect: Connection }) => {
const nextEdges = addEdge(
{
...connect,
type: EDGE_TYPE
},
edges
);
setEdges((state) => setEdges((state) =>
addEdge( addEdge(
{ {
...@@ -922,32 +942,19 @@ export const useWorkflow = () => { ...@@ -922,32 +942,19 @@ export const useWorkflow = () => {
) )
); );
// Add default input
const node = getNodeById(connect.target);
if (!node) return;
// 1. Add file input
if (
node.flowNodeType === FlowNodeTypeEnum.chatNode ||
node.flowNodeType === FlowNodeTypeEnum.toolCall ||
node.flowNodeType === FlowNodeTypeEnum.appModule
) {
const input = node.inputs.find((i) => i.key === NodeInputKeyEnum.fileUrlList);
if (input && (!input?.value || input.value.length === 0)) {
if (!workflowStartNode) return; if (!workflowStartNode) return;
onChangeNode({
nodeId: node.nodeId, const patches = collectWorkflowStartInputAutoFillPatches({
type: 'updateInput', nodes,
key: NodeInputKeyEnum.fileUrlList, edges: nextEdges,
value: { workflowStartNode
...input,
value: [[workflowStartNode.nodeId, NodeOutputKeyEnum.userFiles]]
}
}); });
}
if (patches.length > 0) {
onChangeNode(patches.map((patch) => ({ ...patch, type: 'updateInput' as const })));
} }
}, },
[setEdges, getNodeById, workflowStartNode, onChangeNode] [edges, nodes, onChangeNode, setEdges, workflowStartNode]
); );
const customOnConnect = useCallback( const customOnConnect = useCallback(
(connect: Connection) => { (connect: Connection) => {
......
...@@ -21,6 +21,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -21,6 +21,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { nodeId, inputs } = data; const { nodeId, inputs } = data;
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge);
const CustomComponent = useMemo( const CustomComponent = useMemo(
() => ({ () => ({
...@@ -45,8 +46,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -45,8 +46,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
color={'myGray.600'} color={'myGray.600'}
_hover={{ color: 'red.600' }} _hover={{ color: 'red.600' }}
onClick={() => { onClick={() => {
onChangeNode([ onChangeNode({
{
nodeId, nodeId,
type: 'updateInput', type: 'updateInput',
key: agentKey, key: agentKey,
...@@ -55,13 +55,11 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -55,13 +55,11 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
key: agentKey, key: agentKey,
value: agents.filter((input) => input.key !== item.key) value: agents.filter((input) => input.key !== item.key)
} }
}, });
{ onDelEdge({
nodeId, nodeId,
type: 'delOutput', sourceHandle: getHandleId(nodeId, 'source', item.key)
key: item.key });
}
]);
}} }}
/> />
</MyTooltip> </MyTooltip>
...@@ -129,7 +127,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -129,7 +127,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
); );
} }
}), }),
[nodeId, onChangeNode, t] [nodeId, onChangeNode, onDelEdge, t]
); );
const Render = useMemo(() => { const Render = useMemo(() => {
......
...@@ -87,13 +87,13 @@ const NodeDatasetConcat = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -87,13 +87,13 @@ const NodeDatasetConcat = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
}} }}
/> />
), ),
[NodeInputKeyEnum.datasetQuoteList]: (item: FlowNodeInputItemType) => { [NodeInputKeyEnum.datasetQuoteList]: () => {
return ( return (
<> <>
<HStack className="nodrag" cursor={'default'} position={'relative'}> <HStack className="nodrag" cursor={'default'} position={'relative'}>
<HStack spacing={1} position={'relative'} fontWeight={'medium'} color={'myGray.600'}> <FormLabel required color={'myGray.600'} fontWeight={'medium'}>
<Box>{t('common:core.workflow.Dataset quote')}</Box> {t('common:core.workflow.Dataset quote')}
</HStack> </FormLabel>
<Box flex={'1 0 0'} /> <Box flex={'1 0 0'} />
<Button <Button
variant={'whiteBase'} variant={'whiteBase'}
......
...@@ -56,6 +56,7 @@ import CatchError from '../render/RenderOutput/CatchError'; ...@@ -56,6 +56,7 @@ import CatchError from '../render/RenderOutput/CatchError';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext'; import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
const CurlImportModal = dynamic(() => import('./CurlImportModal')); const CurlImportModal = dynamic(() => import('./CurlImportModal'));
const HeaderAuthConfig = dynamic(() => import('@/components/common/secret/HeaderAuthConfig')); const HeaderAuthConfig = dynamic(() => import('@/components/common/secret/HeaderAuthConfig'));
...@@ -192,9 +193,9 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({ ...@@ -192,9 +193,9 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({
return ( return (
<Box> <Box>
<Box mb={2} display={'flex'} justifyContent={'space-between'}> <Box mb={2} display={'flex'} justifyContent={'space-between'}>
<Box fontWeight={'medium'} color={'myGray.600'}> <FormLabel required={requestUrl?.required} fontWeight={'medium'} color={'myGray.600'}>
{t('common:core.module.Http request settings')} {t('common:core.module.Http request settings')}
</Box> </FormLabel>
<Button variant={'link'} onClick={onOpenCurl}> <Button variant={'link'} onClick={onOpenCurl}>
{t('common:core.module.http.curl import')} {t('common:core.module.http.curl import')}
</Button> </Button>
...@@ -501,6 +502,7 @@ const RenderForm = ({ ...@@ -501,6 +502,7 @@ const RenderForm = ({
const [shouldUpdateNode, setShouldUpdateNode] = useState(false); const [shouldUpdateNode, setShouldUpdateNode] = useState(false);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local list when external input.value changes
setList(input.value || []); setList(input.value || []);
}, [input.value]); }, [input.value]);
...@@ -515,6 +517,7 @@ const RenderForm = ({ ...@@ -515,6 +517,7 @@ const RenderForm = ({
value: list value: list
} }
}); });
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset flag after persisting list to node
setShouldUpdateNode(false); setShouldUpdateNode(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
......
...@@ -12,7 +12,7 @@ import { TTSTypeEnum } from '@/web/core/app/constants'; ...@@ -12,7 +12,7 @@ import { TTSTypeEnum } from '@/web/core/app/constants';
import NodeCard from './render/NodeCard'; import NodeCard from './render/NodeCard';
import ScheduledTriggerConfig from '@/components/core/app/ScheduledTriggerConfig'; import ScheduledTriggerConfig from '@/components/core/app/ScheduledTriggerConfig';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../../context/workflowInitContext'; import { WorkflowBufferDataContext, WorkflowInitContext } from '../../context/workflowInitContext';
import { import {
type AppChatConfigType, type AppChatConfigType,
type AppDetailType, type AppDetailType,
...@@ -26,6 +26,10 @@ import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/wo ...@@ -26,6 +26,10 @@ import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/wo
import Container from '../components/Container'; import Container from '../components/Container';
import AutoExecConfig from '@/components/core/app/AutoExecConfig'; import AutoExecConfig from '@/components/core/app/AutoExecConfig';
import { WorkflowActionsContext } from '../../context/workflowActionsContext'; import { WorkflowActionsContext } from '../../context/workflowActionsContext';
import {
collectWorkflowStartInputAutoFillPatches,
collectWorkflowStartOutputAutoFillRevertPatches
} from '@/web/core/workflow/workflowStartAutoFill';
type ComponentProps = { type ComponentProps = {
chatConfig: AppChatConfigType; chatConfig: AppChatConfigType;
...@@ -250,6 +254,8 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co ...@@ -250,6 +254,8 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co
WorkflowBufferDataContext, WorkflowBufferDataContext,
(v) => v.workflowStartNode (v) => v.workflowStartNode
); );
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
if (!workflowStartNode) return null; if (!workflowStartNode) return null;
...@@ -274,19 +280,45 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co ...@@ -274,19 +280,45 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co
e.canSelectCustomFileExtension; e.canSelectCustomFileExtension;
const repeatKey = workflowStartNode.outputs.find((item) => item.key === userFilesInput.key); const repeatKey = workflowStartNode.outputs.find((item) => item.key === userFilesInput.key);
if (canUploadFiles) { if (canUploadFiles) {
!repeatKey && const patches = collectWorkflowStartInputAutoFillPatches({
onChangeNode({ nodes,
edges,
workflowStartNode: {
...workflowStartNode,
outputs: repeatKey
? workflowStartNode.outputs
: [...workflowStartNode.outputs, userFilesInput]
}
});
onChangeNode([
...(!repeatKey
? [
{
nodeId: workflowStartNode.nodeId, nodeId: workflowStartNode.nodeId,
type: 'addOutput', type: 'addOutput' as const,
value: userFilesInput value: userFilesInput
}
]
: []),
...patches.map((patch) => ({ ...patch, type: 'updateInput' as const }))
]);
} else if (repeatKey) {
const patches = collectWorkflowStartOutputAutoFillRevertPatches({
nodes,
edges,
workflowStartNode,
outputKey: userFilesInput.key
}); });
} else {
repeatKey && onChangeNode([
onChangeNode({ ...patches.map((patch) => ({ ...patch, type: 'updateInput' as const })),
{
nodeId: workflowStartNode.nodeId, nodeId: workflowStartNode.nodeId,
type: 'delOutput', type: 'delOutput',
key: userFilesInput.key key: userFilesInput.key
}); }
]);
} }
}} }}
/> />
......
...@@ -140,9 +140,11 @@ const OptionItem = ({ ...@@ -140,9 +140,11 @@ const OptionItem = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge);
const { key: optionKey, value, ...props } = itemValue; const { key: optionKey, value, ...props } = itemValue;
const options = value as UserSelectOptionItemType[]; const options = value as UserSelectOptionItemType[];
/* eslint-disable react-hooks/refs -- @hello-pangea/dnd passes refs via render props */
return ( return (
<Box <Box
mb={4} mb={4}
...@@ -173,6 +175,10 @@ const OptionItem = ({ ...@@ -173,6 +175,10 @@ const OptionItem = ({
value: options.filter((input) => input.key !== item.key) value: options.filter((input) => input.key !== item.key)
} }
}); });
onDelEdge({
nodeId,
sourceHandle: getHandleId(nodeId, 'source', item.key)
});
}} }}
/> />
</MyTooltip> </MyTooltip>
......
...@@ -26,6 +26,7 @@ import { getClientToolPreviewNode } from '@/web/core/app/api/tool'; ...@@ -26,6 +26,7 @@ import { getClientToolPreviewNode } from '@/web/core/app/api/tool';
import { getAppVersionList } from '@/web/core/app/api/version'; import { getAppVersionList } from '@/web/core/app/api/version';
import { getTeamToolVersions } from '@/web/core/plugin/team/api'; import { getTeamToolVersions } from '@/web/core/plugin/team/api';
import { storeNode2FlowNode } from '@/web/core/workflow/utils'; import { storeNode2FlowNode } from '@/web/core/workflow/utils';
import { getWorkflowCheckIssueUIStatus } from '@/web/core/workflow/workflowCheck';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
...@@ -63,6 +64,7 @@ import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo'; ...@@ -63,6 +64,7 @@ import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import type { SystemToolVersionType } from '@fastgpt/global/core/app/tool/systemTool/type/base'; import type { SystemToolVersionType } from '@fastgpt/global/core/app/tool/systemTool/type/base';
import DebugToolTag from '@fastgpt/web/components/core/plugin/tool/DebugToolTag'; import DebugToolTag from '@fastgpt/web/components/core/plugin/tool/DebugToolTag';
import type { WorkflowCheckIssue } from '@fastgpt/global/core/workflow/type/node';
type Props = FlowNodeItemType & { type Props = FlowNodeItemType & {
children?: React.ReactNode | React.ReactNode[] | string; children?: React.ReactNode | React.ReactNode[] | string;
...@@ -121,6 +123,7 @@ const NodeCard = (props: Props) => { ...@@ -121,6 +123,7 @@ const NodeCard = (props: Props) => {
menuForbid, menuForbid,
isTool = false, isTool = false,
isError = false, isError = false,
workflowCheckIssues,
debugResult, debugResult,
isFolded, isFolded,
customStyle, customStyle,
...@@ -215,6 +218,11 @@ const NodeCard = (props: Props) => { ...@@ -215,6 +218,11 @@ const NodeCard = (props: Props) => {
); );
}, [isFolded, avatar, avatarLinear, name, handleDoubleClick]); }, [isFolded, avatar, avatarLinear, name, handleDoubleClick]);
const errorIssues = useMemo(
() => workflowCheckIssues?.filter((issue) => issue.level === 'error') ?? [],
[workflowCheckIssues]
);
const { outlineColor, outlineWidth } = useMemo(() => { const { outlineColor, outlineWidth } = useMemo(() => {
// error mode // error mode
if (isError) return { outlineColor: '#F97066', outlineWidth: '4px solid' }; if (isError) return { outlineColor: '#F97066', outlineWidth: '4px solid' };
...@@ -352,6 +360,7 @@ const NodeCard = (props: Props) => { ...@@ -352,6 +360,7 @@ const NodeCard = (props: Props) => {
return ( return (
<Flex <Flex
position={'relative'}
outline={selected && (presentationMode || isFolded) ? '16px solid' : undefined} outline={selected && (presentationMode || isFolded) ? '16px solid' : undefined}
outlineColor={'rgba(17, 24, 36, 0.05)'} outlineColor={'rgba(17, 24, 36, 0.05)'}
borderRadius={isFolded ? 26 : 'lg'} borderRadius={isFolded ? 26 : 'lg'}
...@@ -497,12 +506,96 @@ const NodeCard = (props: Props) => { ...@@ -497,12 +506,96 @@ const NodeCard = (props: Props) => {
/> />
)} )}
</Flex> </Flex>
{!isFolded && errorIssues.length > 0 && (
<Box position={'absolute'} top={'100%'} left={0} w={'100%'}>
<NodeWorkflowCheckIssues issues={errorIssues} />
</Box>
)}
</Flex> </Flex>
); );
}; };
export default React.memo(NodeCard); export default React.memo(NodeCard);
/** 待处理/待完善状态图标:待完善用设计稿虚线圆环,待处理用圆形 info。 */
const WorkflowCheckIssueStatusIcon = React.memo(function WorkflowCheckIssueStatusIcon({
status
}: {
status: ReturnType<typeof getWorkflowCheckIssueUIStatus>;
}) {
return (
<MyIcon
name={status === 'pending_handle' ? 'infoRounded' : 'core/app/workflow/checkPendingImprove'}
w={'24px'}
h={'24px'}
flexShrink={0}
color={'#485264'}
/>
);
});
const workflowCheckIssueTextStyle = {
color: 'myGray.600',
fontFamily: 'PingFang SC, PingFang, sans-serif',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: 500,
lineHeight: '24px',
letterSpacing: '0.15px'
} as const;
/** 节点下方校验问题提示条,使用灰色轻量样式而非红色错误条。 */
const NodeWorkflowCheckIssues = React.memo(function NodeWorkflowCheckIssues({
issues
}: {
issues: WorkflowCheckIssue[];
}) {
const { t } = useTranslation();
return (
<Flex flexDirection={'column'} alignItems={'flex-start'} gap={'8px'} mt={2}>
{issues.map((issue, index) => {
const status = getWorkflowCheckIssueUIStatus(issue.code);
// 显式保留静态 key,避免 i18n 清理脚本误删状态前缀文案。
const statusPrefixText =
status === 'pending_handle'
? t('common:core.workflow.check.status.pending_handle')
: t('common:core.workflow.check.status.pending_improve');
return (
<Flex
key={`${issue.code}-${issue.inputKey ?? ''}-${index}`}
display={'inline-flex'}
alignItems={'center'}
gap={'8px'}
px={'16px'}
py={'8px'}
w={'fit-content'}
maxW={'min(720px, 100%)'}
bg={'#E8EBF0'}
opacity={0.8}
borderRadius={'8px'}
>
<WorkflowCheckIssueStatusIcon status={status} />
<Box as={'span'} flexShrink={0} {...workflowCheckIssueTextStyle}>
{statusPrefixText}:
</Box>
<Box
as={'span'}
minW={0}
whiteSpace={'normal'}
wordBreak={'break-word'}
{...workflowCheckIssueTextStyle}
>
{issue.message.trim()}
</Box>
</Flex>
);
})}
</Flex>
);
});
// 节点标题区域组件 // 节点标题区域组件
const NodeTitleSection = React.memo<{ const NodeTitleSection = React.memo<{
nodeId: string; nodeId: string;
......
// 工作流 Node/Edge 操作层 // 工作流 Node/Edge 操作层
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, 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 { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
...@@ -10,8 +10,13 @@ import type { ...@@ -10,8 +10,13 @@ import type {
FlowNodeInputItemType, FlowNodeInputItemType,
FlowNodeOutputItemType FlowNodeOutputItemType
} from '@fastgpt/global/core/workflow/type/io'; } from '@fastgpt/global/core/workflow/type/io';
import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; import type {
FlowNodeTemplateType,
WorkflowCheckNodeIssueMap
} from '@fastgpt/global/core/workflow/type/node';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { checkWorkflowNodeIssues } from '@/web/core/workflow/workflowCheck';
import { collectWorkflowStartAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
type FlowNodeChangeProps = { nodeId: string } & ( type FlowNodeChangeProps = { nodeId: string } & (
...@@ -65,6 +70,12 @@ type WorkflowActionsContextValue = { ...@@ -65,6 +70,12 @@ type WorkflowActionsContextValue = {
/** 更新节点错误状态 */ /** 更新节点错误状态 */
onUpdateNodeError: (nodeId: string, isError: boolean) => void; onUpdateNodeError: (nodeId: string, isError: boolean) => void;
/** 批量同步节点校验问题详情;不改动 isError */
onSyncWorkflowCheckIssues: (nodeIssueMap: WorkflowCheckNodeIssueMap) => void;
/** 单节点刷新校验问题详情,用于节点配置编辑后的局部复查 */
onRefreshSingleNodeWorkflowCheckIssues: (nodeId: string) => void;
/** 移除所有错误状态 */ /** 移除所有错误状态 */
onRemoveError: () => void; onRemoveError: () => void;
...@@ -84,24 +95,37 @@ type WorkflowActionsContextValue = { ...@@ -84,24 +95,37 @@ type WorkflowActionsContextValue = {
setConnectingEdge: React.Dispatch<React.SetStateAction<OnConnectStartParams | undefined>>; setConnectingEdge: React.Dispatch<React.SetStateAction<OnConnectStartParams | undefined>>;
}; };
export const WorkflowActionsContext = createContext<WorkflowActionsContextValue>({ export const WorkflowActionsContext = createContext<WorkflowActionsContextValue>({
onUpdateNodeError: function (nodeId: string, isError: boolean): void { onUpdateNodeError: (...args: Parameters<WorkflowActionsContextValue['onUpdateNodeError']>) => {
void args;
throw new Error('Function not implemented.');
},
onSyncWorkflowCheckIssues: (
...args: Parameters<WorkflowActionsContextValue['onSyncWorkflowCheckIssues']>
) => {
void args;
throw new Error('Function not implemented.');
},
onRefreshSingleNodeWorkflowCheckIssues: (nodeId: string) => {
void nodeId;
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onRemoveError: function (): void { onRemoveError: () => {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onResetNode: function (e: { id: string; node: FlowNodeTemplateType }): void { onResetNode: (...args: Parameters<WorkflowActionsContextValue['onResetNode']>) => {
void args;
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onChangeNode: function (props: FlowNodeChangeProps | FlowNodeChangeProps[]): void { onChangeNode: (...args: Parameters<WorkflowActionsContextValue['onChangeNode']>) => {
void args;
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
onDelEdge: function (e: { nodeId: string; sourceHandle?: string; targetHandle?: string }): void { onDelEdge: (...args: Parameters<WorkflowActionsContextValue['onDelEdge']>) => {
void args;
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
setConnectingEdge: function ( setConnectingEdge: (...args: Parameters<WorkflowActionsContextValue['setConnectingEdge']>) => {
value: React.SetStateAction<OnConnectStartParams | undefined> void args;
): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
} }
}); });
...@@ -114,10 +138,18 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -114,10 +138,18 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
const { toast } = useToast(); const { toast } = useToast();
// 获取 WorkflowBufferDataContext 的数据 // 获取 WorkflowBufferDataContext 的数据
const { forbiddenSaveSnapshot, setEdges, setNodes } = useContextSelector( const {
WorkflowBufferDataContext, forbiddenSaveSnapshot: forbiddenSaveSnapshotRef,
(v) => v setEdges,
); setNodes,
edges,
getNodes
} = useContextSelector(WorkflowBufferDataContext, (v) => v);
const singleNodeCheckTimerRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const edgeCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstEdgesEffectRef = useRef(true);
const prevEdgesRef = useRef(edges);
// 连接状态 // 连接状态
const [connectingEdge, setConnectingEdge] = useState<OnConnectStartParams>(); const [connectingEdge, setConnectingEdge] = useState<OnConnectStartParams>();
...@@ -143,7 +175,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -143,7 +175,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
[setEdges] [setEdges]
); );
// 更新节点错误状态 // 更新节点错误状态;标红时仅保留一个节点的 isError,避免多个节点同时进入选中错误态。
const onUpdateNodeError = useCallback( const onUpdateNodeError = useCallback(
(nodeId: string, isError: boolean) => { (nodeId: string, isError: boolean) => {
setNodes((state) => setNodes((state) =>
...@@ -151,34 +183,196 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -151,34 +183,196 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
if (item.data?.nodeId === nodeId) { if (item.data?.nodeId === nodeId) {
return { return {
...item, ...item,
selected: true, selected: isError ? true : item.selected,
data: { data: {
...item.data, ...item.data,
isError isError
} }
}; };
} }
if (isError && item.data.isError) {
return {
...item,
data: {
...item.data,
isError: false
}
};
}
return item; return item;
}) })
); );
}, },
[setNodes] [setNodes]
); );
/** 同步节点下方问题文案;不改动 isError,标红仅由 onUpdateNodeError 控制。 */
const onSyncWorkflowCheckIssues = useCallback(
(nodeIssueMap: WorkflowCheckNodeIssueMap) => {
setNodes((state) =>
state.map((item) => {
const nodeId = item.data.nodeId;
const issues = nodeIssueMap[nodeId];
const nextIssues = issues?.length ? issues : undefined;
if (JSON.stringify(item.data.workflowCheckIssues) === JSON.stringify(nextIssues)) {
return item;
}
return {
...item,
data: {
...item.data,
workflowCheckIssues: nextIssues
}
};
})
);
},
[setNodes]
);
/** 单节点配置变更后防抖重校验,仅同步问题文案,不自动标红。 */
const onRefreshSingleNodeWorkflowCheckIssues = useCallback(
(nodeId: string) => {
const nodes = getNodes();
const issueMap = checkWorkflowNodeIssues({ nodes, edges, nodeId, t });
setNodes((state) =>
state.map((item) => {
if (item.data.nodeId !== nodeId) return item;
const issues = issueMap[nodeId];
const nextIssues = issues?.length ? issues : undefined;
if (JSON.stringify(item.data.workflowCheckIssues) === JSON.stringify(nextIssues)) {
return item;
}
return {
...item,
data: {
...item.data,
workflowCheckIssues: nextIssues
}
};
})
);
},
[edges, getNodes, setNodes, t]
);
/** 节点配置变更后防抖触发单节点重新校验,避免每次输入都同步扫描。 */
const scheduleSingleNodeWorkflowCheck = useCallback(
(nodeId: string) => {
const existingTimer = singleNodeCheckTimerRef.current.get(nodeId);
if (existingTimer) {
clearTimeout(existingTimer);
}
singleNodeCheckTimerRef.current.set(
nodeId,
setTimeout(() => {
singleNodeCheckTimerRef.current.delete(nodeId);
onRefreshSingleNodeWorkflowCheckIssues(nodeId);
}, 400)
);
},
[onRefreshSingleNodeWorkflowCheckIssues]
);
/** 连线变更后防抖全量扫描,及时更新 no_upstream 等依赖连线的错误态。 */
const scheduleWorkflowCheckOnEdgeChange = useCallback(() => {
if (edgeCheckTimerRef.current) {
clearTimeout(edgeCheckTimerRef.current);
}
edgeCheckTimerRef.current = setTimeout(() => {
edgeCheckTimerRef.current = null;
const nodes = getNodes();
if (nodes.length === 0) return;
const issueMap = checkWorkflowNodeIssues({ nodes, edges, t });
onSyncWorkflowCheckIssues(issueMap);
}, 400);
}, [edges, getNodes, onSyncWorkflowCheckIssues, t]);
useEffect(() => {
if (isFirstEdgesEffectRef.current) {
isFirstEdgesEffectRef.current = false;
prevEdgesRef.current = edges;
return;
}
const prevEdges = prevEdgesRef.current;
const removedEdges = prevEdges.filter(
(prevEdge) => !edges.some((edge) => edge.id === prevEdge.id)
);
prevEdgesRef.current = edges;
if (removedEdges.length > 0) {
const getNodeDataById = (nodeId: string) =>
getNodes().find((node) => node.data.nodeId === nodeId)?.data;
const patches = collectWorkflowStartAutoFillRevertPatches({
removedEdges,
remainingEdges: edges,
getNodeById: getNodeDataById
});
if (patches.length > 0) {
setNodes((nodes) =>
nodes.map((node) => {
const nodePatches = patches.filter((patch) => patch.nodeId === node.data.nodeId);
if (nodePatches.length === 0) return node;
return {
...node,
data: {
...node.data,
inputs: node.data.inputs.map((input) => {
const patch = nodePatches.find((item) => item.key === input.key);
return patch ? patch.value : input;
})
}
};
})
);
}
}
scheduleWorkflowCheckOnEdgeChange();
}, [edges, scheduleWorkflowCheckOnEdgeChange, getNodes, setNodes]);
useEffect(() => {
const timers = singleNodeCheckTimerRef.current;
return () => {
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
if (edgeCheckTimerRef.current) {
clearTimeout(edgeCheckTimerRef.current);
}
};
}, []);
// 移除所有节点的错误状态 // 移除所有节点的错误状态
const onRemoveError = useCallback(() => { const onRemoveError = useCallback(() => {
setNodes((state) => setNodes((state) =>
state.map((item) => { state.map((item) => {
if (item.data.isError) { if (!item.data.isError && !item.data.workflowCheckIssues?.length) {
return item;
}
return { return {
...item, ...item,
selected: false, selected: false,
data: { data: {
...item.data, ...item.data,
isError: false isError: false,
workflowCheckIssues: undefined
} }
}; };
}
return item;
}) })
); );
}, [setNodes]); }, [setNodes]);
...@@ -187,7 +381,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -187,7 +381,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
const onResetNode = useCallback( const onResetNode = useCallback(
({ id, node }: Parameters<WorkflowActionsContextValue['onResetNode']>[0]) => { ({ id, node }: Parameters<WorkflowActionsContextValue['onResetNode']>[0]) => {
// 确保重置时不阻塞快照保存 // 确保重置时不阻塞快照保存
forbiddenSaveSnapshot.current = false; forbiddenSaveSnapshotRef.current = false;
setNodes((state) => setNodes((state) =>
state.map((item) => { state.map((item) => {
...@@ -212,7 +406,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -212,7 +406,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
}) })
); );
}, },
[forbiddenSaveSnapshot, setNodes] [forbiddenSaveSnapshotRef, setNodes]
); );
// 使用结构共享优化的节点更改 // 使用结构共享优化的节点更改
...@@ -229,78 +423,86 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -229,78 +423,86 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
const onChangeNode = useCallback( const onChangeNode = useCallback(
(props: FlowNodeChangeProps | FlowNodeChangeProps[]) => { (props: FlowNodeChangeProps | FlowNodeChangeProps[]) => {
const updateData = Array.isArray(props) ? props : [props]; const updateData = Array.isArray(props) ? props : [props];
const nodeIdsToRecheck = new Set(updateData.map((item) => item.nodeId));
const updatesByNodeId = updateData.reduce((map, item) => {
map.set(item.nodeId, [...(map.get(item.nodeId) ?? []), item]);
return map;
}, new Map<string, FlowNodeChangeProps[]>());
setNodes((nodes) => { setNodes((nodes) => {
return nodes.map((node) => { return nodes.map((node) => {
const updateItem = updateData.find((item) => item.nodeId === node.data.nodeId); const updateItems = updatesByNodeId.get(node.data.nodeId);
if (!updateItem) return node; if (!updateItems?.length) return node;
const { nodeId, type } = updateItem;
// ✅ 使用结构共享,只拷贝变化的部分 // ✅ 使用结构共享,只拷贝变化的部分
let updateObj = node.data; let updateObj = node.data;
updateItems.forEach((updateItem) => {
const { nodeId, type } = updateItem;
if (type === 'attr') { if (type === 'attr') {
// 浅拷贝 + 更新单个属性 // 浅拷贝 + 更新单个属性
updateObj = { updateObj = {
...node.data, ...updateObj,
[updateItem.key]: updateItem.value [updateItem.key]: updateItem.value
}; };
} else if (type === 'updateInput') { } else if (type === 'updateInput') {
// 只拷贝inputs数组 // 批量自动填充会同时更新同一节点的多个 input,需基于上一次变更继续叠加。
updateObj = { updateObj = {
...node.data, ...updateObj,
inputs: node.data.inputs.map((item) => inputs: updateObj.inputs.map((item) =>
item.key === updateItem.key ? updateItem.value : item item.key === updateItem.key ? updateItem.value : item
) )
}; };
} else if (type === 'replaceInput') { } else if (type === 'replaceInput') {
const existingIndex = node.data.inputs.findIndex((item) => item.key === updateItem.key); const existingIndex = updateObj.inputs.findIndex(
(item) => item.key === updateItem.key
);
updateObj = { updateObj = {
...node.data, ...updateObj,
inputs: inputs:
existingIndex === -1 existingIndex === -1
? [...node.data.inputs, updateItem.value] ? [...updateObj.inputs, updateItem.value]
: node.data.inputs.map((item) => : updateObj.inputs.map((item) =>
item.key === updateItem.key ? updateItem.value : item item.key === updateItem.key ? updateItem.value : item
) )
}; };
} else if (type === 'addInput') { } else if (type === 'addInput') {
const hasInput = node.data.inputs.some((input) => input.key === updateItem.value.key); const hasInput = updateObj.inputs.some((input) => input.key === updateItem.value.key);
if (hasInput) { if (hasInput) {
toast({ toast({
status: 'warning', status: 'warning',
title: t('common:key_repetition') title: t('common:key_repetition')
}); });
updateObj = node.data; // 不修改
} else { } else {
updateObj = { updateObj = {
...node.data, ...updateObj,
inputs: [...node.data.inputs, updateItem.value] inputs: [...updateObj.inputs, updateItem.value]
}; };
} }
} else if (type === 'delInput') { } else if (type === 'delInput') {
updateObj = { updateObj = {
...node.data, ...updateObj,
inputs: node.data.inputs.filter((item) => item.key !== updateItem.key) inputs: updateObj.inputs.filter((item) => item.key !== updateItem.key)
}; };
} else if (type === 'updateOutput') { } else if (type === 'updateOutput') {
updateObj = { updateObj = {
...node.data, ...updateObj,
outputs: node.data.outputs.map((item) => outputs: updateObj.outputs.map((item) =>
item.key === updateItem.key ? updateItem.value : item item.key === updateItem.key ? updateItem.value : item
) )
}; };
} else if (type === 'replaceOutput') { } else if (type === 'replaceOutput') {
onDelEdge({ nodeId, sourceHandle: getHandleId(nodeId, 'source', updateItem.key) }); onDelEdge({ nodeId, sourceHandle: getHandleId(nodeId, 'source', updateItem.key) });
updateObj = { updateObj = {
...node.data, ...updateObj,
outputs: node.data.outputs.map((item) => outputs: updateObj.outputs.map((item) =>
item.key === updateItem.key ? updateItem.value : item item.key === updateItem.key ? updateItem.value : item
) )
}; };
} else if (type === 'addOutput') { } else if (type === 'addOutput') {
const hasOutput = node.data.outputs.some( const hasOutput = updateObj.outputs.some(
(output) => output.key === updateItem.value.key (output) => output.key === updateItem.value.key
); );
if (hasOutput) { if (hasOutput) {
...@@ -308,29 +510,29 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -308,29 +510,29 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
status: 'warning', status: 'warning',
title: t('common:key_repetition') title: t('common:key_repetition')
}); });
updateObj = node.data; // 不修改
} else { } else {
if (updateItem.index !== undefined) { if (updateItem.index !== undefined) {
const outputs = [...node.data.outputs]; const outputs = [...updateObj.outputs];
outputs.splice(updateItem.index, 0, updateItem.value); outputs.splice(updateItem.index, 0, updateItem.value);
updateObj = { updateObj = {
...node.data, ...updateObj,
outputs outputs
}; };
} else { } else {
updateObj = { updateObj = {
...node.data, ...updateObj,
outputs: [...node.data.outputs, updateItem.value] outputs: [...updateObj.outputs, updateItem.value]
}; };
} }
} }
} else if (type === 'delOutput') { } else if (type === 'delOutput') {
onDelEdge({ nodeId, sourceHandle: getHandleId(nodeId, 'source', updateItem.key) }); onDelEdge({ nodeId, sourceHandle: getHandleId(nodeId, 'source', updateItem.key) });
updateObj = { updateObj = {
...node.data, ...updateObj,
outputs: node.data.outputs.filter((item) => item.key !== updateItem.key) outputs: updateObj.outputs.filter((item) => item.key !== updateItem.key)
}; };
} }
});
updateObj.outputs = updateObj.outputs.map((output) => { updateObj.outputs = updateObj.outputs.map((output) => {
return { return {
...@@ -347,14 +549,30 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -347,14 +549,30 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
}; };
}); });
}); });
if (updateData.length > 1) {
scheduleWorkflowCheckOnEdgeChange();
} else {
nodeIdsToRecheck.forEach((nodeId) => scheduleSingleNodeWorkflowCheck(nodeId));
}
}, },
[setNodes, toast, t, onDelEdge, llmModelMap] [
setNodes,
toast,
t,
onDelEdge,
llmModelMap,
scheduleSingleNodeWorkflowCheck,
scheduleWorkflowCheckOnEdgeChange
]
); );
const contextValue = useMemo(() => { const contextValue = useMemo(() => {
console.log('WorkflowActionsContextValue 更新了'); console.log('WorkflowActionsContextValue 更新了');
return { return {
onUpdateNodeError, onUpdateNodeError,
onSyncWorkflowCheckIssues,
onRefreshSingleNodeWorkflowCheckIssues,
onRemoveError, onRemoveError,
onResetNode, onResetNode,
onChangeNode, onChangeNode,
...@@ -362,7 +580,16 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...@@ -362,7 +580,16 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod
connectingEdge, connectingEdge,
setConnectingEdge setConnectingEdge
}; };
}, [onUpdateNodeError, onRemoveError, onResetNode, onChangeNode, onDelEdge, connectingEdge]); }, [
onUpdateNodeError,
onSyncWorkflowCheckIssues,
onRefreshSingleNodeWorkflowCheckIssues,
onRemoveError,
onResetNode,
onChangeNode,
onDelEdge,
connectingEdge
]);
return ( return (
<WorkflowActionsContext.Provider value={contextValue}> <WorkflowActionsContext.Provider value={contextValue}>
......
// 工作流工具函数层 // 工作流工具函数层
import React, { type ReactNode, useCallback, useMemo, useContext } from 'react'; import React, { type ReactNode, useCallback, useEffect, useMemo } from 'react';
import { createContext, useContextSelector } from 'use-context-selector'; import { createContext, useContextSelector } from 'use-context-selector';
import { useReactFlow } from 'reactflow'; import { useReactFlow } from 'reactflow';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { import {
checkWorkflowNodeAndConnection,
adaptCatchError, adaptCatchError,
storeNode2FlowNode, storeNode2FlowNode,
storeEdge2RenderEdge storeEdge2RenderEdge
} from '@/web/core/workflow/utils'; } from '@/web/core/workflow/utils';
import {
checkWorkflowBeforeRunOrPublish,
checkWorkflowNodeIssues
} from '@/web/core/workflow/workflowCheck';
import { uiWorkflow2StoreWorkflow } from '../utils'; import { uiWorkflow2StoreWorkflow } from '../utils';
import { import {
FlowNodeOutputTypeEnum, FlowNodeOutputTypeEnum,
...@@ -67,47 +70,25 @@ type WorkflowUtilsContextValue = { ...@@ -67,47 +70,25 @@ type WorkflowUtilsContextValue = {
}; };
}; };
export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({ export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({
initData: function ( initData: (...args: Parameters<WorkflowUtilsContextValue['initData']>) => {
e: { void args;
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
},
isInit?: boolean
): Promise<void> {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
flowData2StoreData: function (): flowData2StoreData: () => {
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
flowData2StoreDataAndCheck: function (hideTip?: boolean): flowData2StoreDataAndCheck: (
| { ...args: Parameters<WorkflowUtilsContextValue['flowData2StoreDataAndCheck']>
nodes: StoreNodeItemType[]; ) => {
edges: StoreEdgeItemType[]; void args;
}
| undefined {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
splitOutput: function (outputs: FlowNodeOutputItemType[]): { splitOutput: (...args: Parameters<WorkflowUtilsContextValue['splitOutput']>) => {
successOutputs: FlowNodeOutputItemType[]; void args;
hiddenOutputs: FlowNodeOutputItemType[];
errorOutputs: FlowNodeOutputItemType[];
} {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
splitToolInputs: function ( splitToolInputs: (...args: Parameters<WorkflowUtilsContextValue['splitToolInputs']>) => {
inputs: FlowNodeInputItemType[], void args;
nodeId: string
): {
isTool: boolean;
toolInputs: FlowNodeInputItemType[];
commonInputs: FlowNodeInputItemType[];
} {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
} }
}); });
...@@ -115,7 +96,7 @@ export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({ ...@@ -115,7 +96,7 @@ export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({
export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => { export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const { fitView, getViewport, setViewport } = useReactFlow(); const { fitView } = useReactFlow();
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore(); const { teamPlanStatus } = useUserStore();
const showSandbox = feConfigs?.show_agent_sandbox; const showSandbox = feConfigs?.show_agent_sandbox;
...@@ -127,7 +108,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -127,7 +108,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
(v) => v (v) => v
); );
const { past, setPast } = useContextSelector(WorkflowSnapshotContext, (v) => v); const { past, setPast } = useContextSelector(WorkflowSnapshotContext, (v) => v);
const { onRemoveError, onUpdateNodeError, onChangeNode } = useContextSelector( const { onRemoveError, onUpdateNodeError, onSyncWorkflowCheckIssues } = useContextSelector(
WorkflowActionsContext, WorkflowActionsContext,
(v) => v (v) => v
); );
...@@ -225,21 +206,32 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -225,21 +206,32 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
return; return;
} }
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges }); const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({
nodes,
edges,
t
});
if (!checkResults) { if (!hasError) {
onRemoveError(); onRemoveError();
const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges }); const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges });
return storeWorkflow; return storeWorkflow;
} else if (!hideTip) { }
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
if (!hideTip) {
onSyncWorkflowCheckIssues(issueMap);
// View move to the node that failed if (firstErrorNodeId) {
onUpdateNodeError(firstErrorNodeId, true);
const firstErrorNode = nodes.find((node) => node.data.nodeId === firstErrorNodeId);
if (firstErrorNode) {
fitView({ fitView({
nodes: nodes.filter((node) => checkResults.includes(node.data.nodeId)), nodes: [firstErrorNode],
padding: 0.3 padding: 0.3
}); });
}
}
toast({ toast({
status: 'warning', status: 'warning',
...@@ -251,15 +243,33 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ...@@ -251,15 +243,33 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
getNodes, getNodes,
edges, edges,
onRemoveError, onRemoveError,
onSyncWorkflowCheckIssues,
fitView, fitView,
toast,
t, t,
onUpdateNodeError, onUpdateNodeError,
showSandbox, showSandbox,
enableSandbox enableSandbox,
toast
] ]
); );
/** 编辑页定时全量扫描,主动发现新增/已修复的节点错误。 */
useEffect(() => {
const runScheduledCheck = () => {
const nodes = getNodes();
if (nodes.length === 0) return;
const issueMap = checkWorkflowNodeIssues({ nodes, edges, t });
onSyncWorkflowCheckIssues(issueMap);
};
const timer = window.setInterval(runScheduledCheck, 10_000);
return () => {
window.clearInterval(timer);
};
}, [edges, getNodes, onSyncWorkflowCheckIssues, t]);
// 4. initData - 初始化工作流数据 // 4. initData - 初始化工作流数据
const initData = useCallback( const initData = useCallback(
async ( async (
......
...@@ -20,7 +20,6 @@ import { ...@@ -20,7 +20,6 @@ import {
formatEditorVariablePickerIcon, formatEditorVariablePickerIcon,
getAppChatConfig, getAppChatConfig,
getHandleId, getHandleId,
isValidReferenceValue,
isValidReferenceValueFormat, isValidReferenceValueFormat,
nodeInputIsReference nodeInputIsReference
} from '@fastgpt/global/core/workflow/utils'; } from '@fastgpt/global/core/workflow/utils';
...@@ -36,9 +35,6 @@ import { ...@@ -36,9 +35,6 @@ import {
initNewIfElseList, initNewIfElseList,
normalizeIfElseList normalizeIfElseList
} from '@fastgpt/global/core/workflow/template/system/ifElse/utils'; } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type';
import { type AppChatConfigType } from '@fastgpt/global/core/app/type'; import { type AppChatConfigType } from '@fastgpt/global/core/app/type';
import { cloneDeep, isEqual } from 'lodash-es'; import { cloneDeep, isEqual } from 'lodash-es';
import { workflowSystemVariables } from '../app/utils'; import { workflowSystemVariables } from '../app/utils';
...@@ -639,349 +635,6 @@ export const getNodeAllSource = ({ ...@@ -639,349 +635,6 @@ export const getNodeAllSource = ({
return Array.from(sourceNodes.values()); return Array.from(sourceNodes.values());
}; };
/* ====== Connection ======= */
// Connectivity check result type
type ConnectivityIssue = {
nodeId: string;
issue: 'isolated' | 'no_input' | 'unreachable_from_start';
};
export const checkWorkflowNodeAndConnection = ({
nodes,
edges
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
}): string[] | undefined => {
// Node check
for (const node of nodes) {
const data = node.data;
const inputs = data.inputs;
const isToolNode = edges.some(
(edge) =>
edge.targetHandle === NodeOutputKeyEnum.selectedTools && edge.target === node.data.nodeId
);
if (data.pluginData?.error) {
return [data.nodeId];
}
if (
data.flowNodeType === FlowNodeTypeEnum.systemConfig ||
data.flowNodeType === FlowNodeTypeEnum.pluginConfig ||
data.flowNodeType === FlowNodeTypeEnum.pluginInput ||
data.flowNodeType === FlowNodeTypeEnum.workflowStart ||
data.flowNodeType === FlowNodeTypeEnum.comment
) {
continue;
}
if (data.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
const ifElseList: IfElseListItemType[] = inputs.find(
(input) => input.key === NodeInputKeyEnum.ifElseList
)?.value;
if (
ifElseList.some((item) => {
return item.list.some((listItem) => {
return (
listItem.variable === undefined ||
listItem.condition === undefined ||
(listItem.value === undefined &&
listItem.condition !== VariableConditionEnum.isEmpty &&
listItem.condition !== VariableConditionEnum.isNotEmpty)
);
});
})
) {
return [data.nodeId];
} else {
continue;
}
}
if (data.flowNodeType === FlowNodeTypeEnum.userSelect) {
const configValue = data.inputs.find(
(input) => input.key === NodeInputKeyEnum.userSelectOptions
)?.value;
if (
!configValue ||
configValue.length === 0 ||
configValue.some((item: any) => !item.value)
) {
return [data.nodeId];
}
}
if (data.flowNodeType === FlowNodeTypeEnum.formInput) {
const value = data.inputs.find(
(input) => input.key === NodeInputKeyEnum.userInputForms
)?.value;
if (!value || value.length === 0) {
return [data.nodeId];
}
}
if (data.flowNodeType === FlowNodeTypeEnum.loopRun) {
const mode = inputs.find((input) => input.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined;
if (mode === LoopRunModeEnum.conditional) {
const children =
(inputs.find((input) => input.key === NodeInputKeyEnum.childrenNodeIdList)
?.value as string[]) ?? [];
const childSet = new Set(children);
const hasBreak = nodes.some(
(n) =>
childSet.has(n.data.nodeId) && n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
return [data.nodeId];
}
}
}
if (data.flowNodeType === FlowNodeTypeEnum.toolCall) {
const toolConnections = edges.filter(
(edge) =>
edge.source === data.nodeId && edge.sourceHandle === NodeOutputKeyEnum.selectedTools
);
const useAgentSandbox = inputs.find(
(input) => input.key === NodeInputKeyEnum.useAgentSandbox
)?.value;
if (toolConnections.length === 0 && !useAgentSandbox) {
return [data.nodeId];
}
}
if (data.flowNodeType === FlowNodeTypeEnum.variableUpdate) {
const updateList: TUpdateListItem[] = inputs.find(
(input) => input.key === NodeInputKeyEnum.updateList
)?.value;
const nodeIds = nodes.map((n) => n.data.nodeId);
const isLiveReference = (value: ReferenceItemValueType | undefined) => {
if (!isValidReferenceValueFormat(value)) return false;
const [refNodeId, refOutputId] = value;
if (!refNodeId || !refOutputId) return false;
if (refNodeId === VARIABLE_NODE_ID) return true;
return !!nodes
.find((node) => node.data.nodeId === refNodeId)
?.data.outputs.find((output) => output.id === refOutputId);
};
if (
!updateList ||
updateList.length === 0 ||
updateList.some((item) => {
if (!isValidReferenceValue(item.variable, nodeIds) || !isLiveReference(item.variable))
return true;
if (item.renderType === FlowNodeInputTypeEnum.reference) {
// 接受单引用 [ref] 与引用数组 [[ref], ...],与 dispatcher 对齐
if (isValidReferenceValueFormat(item.value)) {
return !isLiveReference(item.value as ReferenceItemValueType);
}
return (
!Array.isArray(item.value) ||
item.value.length === 0 ||
(item.value as ReferenceItemValueType[]).some((v) => !isLiveReference(v))
);
}
// input 模式:clear / boolean 由模式字段决定,不读 value
if (item.arrayMode === 'clear') return false;
if (item.booleanMode) return false;
const inputVal = item.value?.[1];
return inputVal === undefined || inputVal === null || inputVal === '';
})
) {
return [data.nodeId];
} else {
continue;
}
}
if (
inputs.some((input) => {
// Conditional loopRun hides loopRunInputArray in the UI; its required flag is
// only meaningful in array mode, so skip it here to avoid spurious failures.
if (input.key === NodeInputKeyEnum.loopRunInputArray) {
const loopRunMode =
data.flowNodeType === FlowNodeTypeEnum.loopRun
? (inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined)
: undefined;
if (loopRunMode === LoopRunModeEnum.conditional) {
return false;
}
}
if (
!input.valueType ||
[WorkflowIOValueTypeEnum.any, WorkflowIOValueTypeEnum.boolean].includes(input.valueType)
) {
return false;
}
// check is tool input
if (isToolNode && input.toolDescription) {
return false;
}
if (input.required) {
if (input.value === undefined && input.valueType !== WorkflowIOValueTypeEnum.boolean) {
return true;
}
if (Array.isArray(input.value) && input.value.length === 0) return true;
}
// check reference invalid
if (nodeInputIsReference(input)) {
// 无效引用时,返回 true
const checkValueValid = (value: ReferenceItemValueType) => {
const nodeId = value?.[0];
const outputId = value?.[1];
if (!nodeId || !outputId) return false;
if (nodeId === VARIABLE_NODE_ID) {
return true;
}
return !!nodes
.find((node) => node.data.nodeId === nodeId)
?.data.outputs.find((output) => output.id === outputId);
};
if (input.valueType?.startsWith('array')) {
input.value = input.value ?? [];
// 如果内容为空,则报错
if (input.required && input.value.length === 0) {
return true;
}
} else {
// Single reference
if (input.required) {
return !checkValueValid(input.value);
}
}
}
return false;
})
) {
return [data.nodeId];
}
// Check node has invalid edge
const edgeFilted = edges.filter(
(edge) =>
!(
data.flowNodeType === FlowNodeTypeEnum.toolCall &&
edge.sourceHandle === NodeOutputKeyEnum.selectedTools
)
);
// Check node has edge
const hasEdge = edgeFilted.some(
(edge) => edge.source === data.nodeId || edge.target === data.nodeId
);
if (!hasEdge) {
return [data.nodeId];
}
}
// Edge check
/**
* Check graph connectivity and identify connectivity issues
*/
const checkConnectivity = (
nodes: Node<FlowNodeItemType, string | undefined>[],
edges: Edge<any>[]
): string[] => {
// Find start node
const startNode = nodes.find(
(node) =>
node.data.flowNodeType === FlowNodeTypeEnum.workflowStart ||
node.data.flowNodeType === FlowNodeTypeEnum.pluginInput
);
if (!startNode) {
// No start node found - this is a critical issue
return nodes.map((node) => node.data.nodeId);
}
const issues: ConnectivityIssue[] = [];
// Build adjacency lists for both directions
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
nodes.forEach((node) => {
outgoing.set(node.data.nodeId, []);
incoming.set(node.data.nodeId, []);
});
edges.forEach((edge) => {
const outList = outgoing.get(edge.source) || [];
outList.push(edge.target);
outgoing.set(edge.source, outList);
const inList = incoming.get(edge.target) || [];
inList.push(edge.source);
incoming.set(edge.target, inList);
});
// Check reachability from start node(Start node/Loop start 可以到达的地方)
const reachableFromStart = new Set<string>();
const dfsFromStart = (nodeId: string) => {
if (reachableFromStart.has(nodeId)) return;
reachableFromStart.add(nodeId);
const neighbors = outgoing.get(nodeId) || [];
neighbors.forEach((neighbor) => dfsFromStart(neighbor));
};
dfsFromStart(startNode.data.nodeId);
nodes.forEach((node) => {
if (
node.data.flowNodeType === FlowNodeTypeEnum.nestedStart ||
node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart
) {
dfsFromStart(node.data.nodeId);
}
});
// Check each node for connectivity issues
for (const node of nodes) {
const nodeId = node.data.nodeId;
const nodeType = node.data.flowNodeType;
// Skip system nodes that don't need connectivity checks
if (
nodeType === FlowNodeTypeEnum.systemConfig ||
nodeType === FlowNodeTypeEnum.pluginConfig ||
nodeType === FlowNodeTypeEnum.comment ||
nodeType === FlowNodeTypeEnum.globalVariable ||
nodeType === FlowNodeTypeEnum.emptyNode
) {
continue;
}
const isStartNode = [
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.loopRunStart
].includes(nodeType);
// Check if node is reachable from start
if (!isStartNode && !reachableFromStart.has(nodeId)) {
issues.push({
nodeId,
issue: 'unreachable_from_start'
});
break;
}
}
return issues.map((issue) => issue.nodeId);
};
const connectivityIssues = checkConnectivity(nodes, edges);
if (connectivityIssues.length > 0) {
return connectivityIssues;
}
};
/* ====== Variables ======= */ /* ====== Variables ======= */
/* get workflowStart output to global variables */ /* get workflowStart output to global variables */
export const getWorkflowGlobalVariables = ({ export const getWorkflowGlobalVariables = ({
......
import type {
WorkflowCheckIssue,
WorkflowCheckNodeIssueMap
} from '@fastgpt/global/core/workflow/type/node';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { Edge, Node } from 'reactflow';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
VARIABLE_NODE_ID,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import {
getHandleId,
isValidReferenceValue,
isValidReferenceValueFormat,
nodeInputIsReference
} from '@fastgpt/global/core/workflow/utils';
import type { TFunction } from 'next-i18next';
import type {
FlowNodeInputItemType,
ReferenceItemValueType
} from '@fastgpt/global/core/workflow/type/io';
import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import type { TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type';
import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type';
import { AppErrEnum } from '@fastgpt/global/common/error/code/app';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
import { getToolConfigStatus } from '@fastgpt/global/core/app/formEdit/utils';
type WorkflowCheckContext = {
nodeMap: Map<string, Node<FlowNodeItemType, string | undefined>>;
nodeOutputMap: Map<string, Set<string>>;
incomingEdgesMap: Map<string, Edge<any>[]>;
outgoingEdgesMap: Map<string, Edge<any>[]>;
reachableNodeSet: Set<string>;
};
const workflowCheckSkipConnectionTypes = new Set<FlowNodeTypeEnum>([
FlowNodeTypeEnum.systemConfig,
FlowNodeTypeEnum.pluginConfig,
FlowNodeTypeEnum.comment,
FlowNodeTypeEnum.globalVariable,
FlowNodeTypeEnum.emptyNode
]);
/**
* 多分支节点的 sourceHandle 必须与当前 options/agents 中的 key 一致;
* 删除分支后残留的悬空 edge 不应计入有效连线。
*/
const isWorkflowEdgeSourceHandleValid = (
sourceNode: Node<FlowNodeItemType, string | undefined> | undefined,
sourceHandle: string | null | undefined
) => {
if (!sourceNode) return false;
const { nodeId, flowNodeType, inputs } = sourceNode.data;
if (flowNodeType === FlowNodeTypeEnum.userSelect) {
if (!sourceHandle) return false;
const options = inputs?.find((input) => input.key === NodeInputKeyEnum.userSelectOptions)
?.value as Array<{ key?: string }> | undefined;
return (
Array.isArray(options) &&
options.some(
(option) => option.key && sourceHandle === getHandleId(nodeId, 'source', option.key)
)
);
}
if (flowNodeType === FlowNodeTypeEnum.classifyQuestion) {
if (!sourceHandle) return false;
const agents = inputs?.find((input) => input.key === NodeInputKeyEnum.agents)?.value as
| Array<{ key?: string }>
| undefined;
return (
Array.isArray(agents) &&
agents.some((agent) => agent.key && sourceHandle === getHandleId(nodeId, 'source', agent.key))
);
}
return true;
};
const workflowCheckStartTypes = new Set<FlowNodeTypeEnum>([
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.loopRunStart
]);
const workflowCheckSkipNodeRuleTypes = new Set<FlowNodeTypeEnum>([
FlowNodeTypeEnum.systemConfig,
FlowNodeTypeEnum.pluginConfig,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.comment
]);
const isEmptyWorkflowInputValue = (value: unknown) =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
/** hidden / 非必填 any 不参与通用必填校验,避免系统 hidden 字段误报或与节点特判重复。 */
const shouldSkipGenericRequiredInputCheck = (input: FlowNodeInputItemType) => {
const renderType = input.renderTypeList?.[input.selectedTypeIndex ?? 0];
if (renderType === FlowNodeInputTypeEnum.hidden) return true;
if (!input.valueType) return true;
if (input.valueType === WorkflowIOValueTypeEnum.boolean) return true;
if (input.valueType === WorkflowIOValueTypeEnum.any) {
return !input.required;
}
return false;
};
/** 优先取 label,空则取 debugLabel,并走 i18n 翻译,避免展示 quoteQA 等内部 key。 */
const getInputLabel = (input: FlowNodeInputItemType, t?: TFunction) => {
const rawLabel =
(typeof input.label === 'string' && input.label ? input.label : undefined) ||
(typeof input.debugLabel === 'string' && input.debugLabel ? input.debugLabel : undefined) ||
input.key;
if (t && rawLabel) {
return t(rawLabel as any);
}
return rawLabel;
};
/** 设计稿固定提示文案 code,与 issue.code 一一对应或作为文案模板。 */
type WorkflowCheckMessageCode =
| 'required_input_empty'
| 'no_upstream'
| 'invalid_reference'
| 'if_else_incomplete'
| 'user_select_empty'
| 'user_select_value_empty'
| 'form_input_empty'
| 'classify_question_empty'
| 'classify_question_value_empty'
| 'code_input_incomplete'
| 'http_url_empty'
| 'context_extract_empty'
| 'tool_call_empty'
| 'tool_inactive'
| 'tool_missing'
| 'tool_load_failed'
| 'tool_no_permission'
| 'tool_offline';
/** issue.code -> 设计稿固定文案 code。表外 code 映射到最接近的已有文案。 */
const WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP: Record<string, WorkflowCheckMessageCode> = {
required_input_empty: 'required_input_empty',
no_upstream: 'no_upstream',
isolated_node: 'no_upstream',
unreachable_from_start: 'no_upstream',
invalid_reference: 'invalid_reference',
if_else_incomplete: 'if_else_incomplete',
user_select_empty: 'user_select_empty',
user_select_value_empty: 'user_select_value_empty',
form_input_empty: 'form_input_empty',
classify_question_empty: 'classify_question_empty',
classify_question_value_empty: 'classify_question_value_empty',
code_input_incomplete: 'code_input_incomplete',
http_url_empty: 'http_url_empty',
context_extract_empty: 'context_extract_empty',
tool_call_empty: 'tool_call_empty',
tool_inactive: 'tool_inactive',
tool_waiting_config: 'tool_inactive',
tool_missing: 'tool_missing',
tool_load_failed: 'tool_load_failed',
tool_no_permission: 'tool_no_permission',
tool_offline: 'tool_offline',
loop_run_missing_break: 'if_else_incomplete',
variable_update_incomplete: 'code_input_incomplete'
};
/** 待处理:引用无效、工具不可访问或加载失败。其余均为待完善。 */
export const WORKFLOW_CHECK_PENDING_HANDLE_CODES = new Set<string>([
'invalid_reference',
'tool_missing',
'tool_load_failed',
'tool_no_permission',
'tool_offline'
]);
export type WorkflowCheckUIStatus = 'pending_improve' | 'pending_handle';
/** 按 issue code 映射 UI 状态前缀,不直接使用 level 字段。 */
export const getWorkflowCheckIssueUIStatus = (code: string): WorkflowCheckUIStatus =>
WORKFLOW_CHECK_PENDING_HANDLE_CODES.has(code) ? 'pending_handle' : 'pending_improve';
const workflowCheckMessageFallback: Record<
WorkflowCheckMessageCode,
(params?: { inputName?: string }) => string
> = {
required_input_empty: ({ inputName } = {}) => `需填写必填项 ${inputName ?? ''}`.trim(),
no_upstream: () => '未与其他节点连线',
invalid_reference: ({ inputName } = {}) => `${inputName ?? ''} 引用了无效变量,需删除`.trim(),
if_else_incomplete: () => '存在未完成的条件配置,请完善',
user_select_empty: () => '需配置至少一个选项',
user_select_value_empty: () => '选项不可为空',
form_input_empty: () => '需配置至少一个字段',
classify_question_empty: () => '需配置至少一个分类',
classify_question_value_empty: () => '分类值不可为空',
code_input_incomplete: () => '存在未完成的输入变量配置,请完善',
http_url_empty: () => '需配置请求地址',
context_extract_empty: () => '需配置至少一个目标字段',
tool_call_empty: () => '需配置工具或开启虚拟机',
tool_inactive: () => '该工具尚未激活,请激活使用',
tool_missing: () => '该工具不存在,请删除',
tool_load_failed: () => '工具加载失败,请稍后重试',
tool_no_permission: () => '当前账号无权限访问该资源',
tool_offline: () => '该工具已停用,请删除'
};
const PLUGIN_DATA_PERMISSION_ERROR_CODES = new Set<string>([
AppErrEnum.unAuthApp,
PluginErrEnum.unAuth
]);
const PLUGIN_DATA_MISSING_ERROR_CODES = new Set<string>([
AppErrEnum.unExist,
PluginErrEnum.unExist
]);
/** pluginData.error 可能是 statusText 或 getErrText 翻译后的 message,需两种都识别。 */
const resolvePluginDataErrorIssueCode = (error: string): WorkflowCheckMessageCode => {
if (
PLUGIN_DATA_PERMISSION_ERROR_CODES.has(error) ||
error === ERROR_RESPONSE[AppErrEnum.unAuthApp]?.message ||
error === ERROR_RESPONSE[PluginErrEnum.unAuth]?.message
) {
return 'tool_no_permission';
}
if (
PLUGIN_DATA_MISSING_ERROR_CODES.has(error) ||
error === ERROR_RESPONSE[AppErrEnum.unExist]?.message ||
error === ERROR_RESPONSE[PluginErrEnum.unExist]?.message
) {
return 'tool_missing';
}
return 'tool_load_failed';
};
const resolveWorkflowCheckMessageCode = (issueCode: string): WorkflowCheckMessageCode | undefined =>
WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP[issueCode];
/**
* 使用显式分支保留所有翻译 key 的静态字面量引用,避免 i18n 清理脚本误删动态 key。
*/
const translateWorkflowCheckIssueMessage = (
messageCode: WorkflowCheckMessageCode,
t: TFunction,
params?: { inputName?: string }
) => {
switch (messageCode) {
case 'required_input_empty':
return t('common:core.workflow.check.required_input_empty', params);
case 'no_upstream':
return t('common:core.workflow.check.no_upstream', params);
case 'invalid_reference':
return t('common:core.workflow.check.invalid_reference', params);
case 'if_else_incomplete':
return t('common:core.workflow.check.if_else_incomplete', params);
case 'user_select_empty':
return t('common:core.workflow.check.user_select_empty', params);
case 'user_select_value_empty':
return t('common:core.workflow.check.user_select_value_empty', params);
case 'form_input_empty':
return t('common:core.workflow.check.form_input_empty', params);
case 'classify_question_empty':
return t('common:core.workflow.check.classify_question_empty', params);
case 'classify_question_value_empty':
return t('common:core.workflow.check.classify_question_value_empty', params);
case 'code_input_incomplete':
return t('common:core.workflow.check.code_input_incomplete', params);
case 'http_url_empty':
return t('common:core.workflow.check.http_url_empty', params);
case 'context_extract_empty':
return t('common:core.workflow.check.context_extract_empty', params);
case 'tool_call_empty':
return t('common:core.workflow.check.tool_call_empty', params);
case 'tool_inactive':
return t('common:core.workflow.check.tool_inactive', params);
case 'tool_missing':
return t('common:core.workflow.check.tool_missing', params);
case 'tool_load_failed':
return t('common:core.workflow.check.tool_load_failed', params);
case 'tool_no_permission':
return t('common:core.workflow.check.tool_no_permission', params);
case 'tool_offline':
return t('common:core.workflow.check.tool_offline', params);
}
};
/** 根据 issue.code 返回设计稿固定提示文案,不自由拼接或生成表外文案。 */
export const getWorkflowCheckIssueMessage = (
issueCode: string,
t?: TFunction,
params?: { inputName?: string }
) => {
const messageCode = resolveWorkflowCheckMessageCode(issueCode);
if (!messageCode) return '';
if (t) {
return translateWorkflowCheckIssueMessage(messageCode, t, params);
}
return workflowCheckMessageFallback[messageCode](params);
};
const createWorkflowCheckContext = ({
nodes,
edges
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
}): WorkflowCheckContext => {
const nodeMap = new Map<string, Node<FlowNodeItemType, string | undefined>>();
const nodeOutputMap = new Map<string, Set<string>>();
const incomingEdgesMap = new Map<string, Edge<any>[]>();
const outgoingEdgesMap = new Map<string, Edge<any>[]>();
nodes.forEach((node) => {
nodeMap.set(node.data.nodeId, node);
nodeOutputMap.set(node.data.nodeId, new Set(node.data.outputs.map((output) => output.id)));
incomingEdgesMap.set(node.data.nodeId, []);
outgoingEdgesMap.set(node.data.nodeId, []);
});
edges.forEach((edge) => {
const sourceNode = nodeMap.get(edge.source);
if (!isWorkflowEdgeSourceHandleValid(sourceNode, edge.sourceHandle)) {
return;
}
outgoingEdgesMap.get(edge.source)?.push(edge);
incomingEdgesMap.get(edge.target)?.push(edge);
});
const reachableNodeSet = new Set<string>();
const visit = (nodeId: string) => {
if (reachableNodeSet.has(nodeId)) return;
reachableNodeSet.add(nodeId);
outgoingEdgesMap.get(nodeId)?.forEach((edge) => visit(edge.target));
};
nodes.forEach((node) => {
if (
node.data.flowNodeType === FlowNodeTypeEnum.workflowStart ||
node.data.flowNodeType === FlowNodeTypeEnum.pluginInput ||
node.data.flowNodeType === FlowNodeTypeEnum.nestedStart ||
node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart
) {
visit(node.data.nodeId);
}
});
return {
nodeMap,
nodeOutputMap,
incomingEdgesMap,
outgoingEdgesMap,
reachableNodeSet
};
};
const referenceValueIsLive = (
value: ReferenceItemValueType | undefined,
context: WorkflowCheckContext
) => {
if (!isValidReferenceValueFormat(value)) return false;
const [refNodeId, refOutputId] = value;
if (!refNodeId || !refOutputId) return false;
if (refNodeId === VARIABLE_NODE_ID) return true;
return context.nodeOutputMap.get(refNodeId)?.has(refOutputId) === true;
};
/** 引用输入是否尚未选择(空占位 / 未选变量),区别于曾经选中但已失效的引用。 */
const isUnsetReferenceValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return true;
if (!Array.isArray(value)) return true;
if (value.length === 0) return true;
// 单引用 [nodeId, outputId];占位符 ['', ''] 或格式不完整均视为未选择
if (value.length === 2 && !Array.isArray(value[0])) {
const [refNodeId, refOutputId] = value;
if (typeof refNodeId !== 'string') return true;
return !refNodeId || !refOutputId;
}
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) => {
if (isArrayType) {
return !Array.isArray(value) || value.length === 0;
}
return isUnsetReferenceValue(value);
};
const isVariableUpdateTargetEmpty = (
variable: unknown,
nodeIds: string[],
context: WorkflowCheckContext
) =>
!isValidReferenceValue(variable, nodeIds) ||
!referenceValueIsLive(variable as ReferenceItemValueType, context);
const isVariableUpdateValueEmpty = (item: TUpdateListItem, context: WorkflowCheckContext) => {
if (item.renderType === FlowNodeInputTypeEnum.reference) {
if (isValidReferenceValueFormat(item.value)) {
return !referenceValueIsLive(item.value as ReferenceItemValueType, context);
}
return (
!Array.isArray(item.value) ||
item.value.length === 0 ||
(item.value as ReferenceItemValueType[]).some((v) => !referenceValueIsLive(v, context))
);
}
if (item.arrayMode === 'clear') return false;
if (item.booleanMode) return false;
const inputVal = item.value?.[1];
return inputVal === undefined || inputVal === null || inputVal === '';
};
/**
* 结构化校验工作流节点和连线。
* 函数只读取入参并返回每个节点的错误列表,调用方负责写入 React state、toast 或定位画布。
*/
export const checkWorkflowNodeIssues = ({
nodes,
edges,
nodeId,
t
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
nodeId?: string;
t?: TFunction;
}): WorkflowCheckNodeIssueMap => {
const context = createWorkflowCheckContext({ nodes, edges });
const issueMap: WorkflowCheckNodeIssueMap = {};
const nodeIds = nodes.map((node) => node.data.nodeId);
const targetNodes = nodeId ? nodes.filter((node) => node.data.nodeId === nodeId) : nodes;
const addIssue = ({
node,
code,
message,
inputKey
}: {
node: Node<FlowNodeItemType, string | undefined>;
code: string;
message: string;
inputKey?: string;
}) => {
const issue: WorkflowCheckIssue = {
nodeId: node.data.nodeId,
nodeName: node.data.name,
nodeType: node.data.flowNodeType,
level: 'error',
code,
message,
inputKey
};
issueMap[node.data.nodeId] = [...(issueMap[node.data.nodeId] ?? []), issue];
};
for (const node of targetNodes) {
const data = node.data;
const inputs = data.inputs;
const inputMap = new Map(inputs.map((input) => [input.key, input]));
const isToolNode = context.incomingEdgesMap
.get(data.nodeId)
?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools);
if (data.pluginData?.error) {
const issueCode = resolvePluginDataErrorIssueCode(data.pluginData.error);
addIssue({
node,
code: issueCode,
message: getWorkflowCheckIssueMessage(issueCode, t)
});
}
const status = data.status ?? data.pluginData?.status;
if (status === PluginStatusEnum.Offline) {
addIssue({
node,
code: 'tool_offline',
message: getWorkflowCheckIssueMessage('tool_offline', t)
});
}
// 工具调用下游工具:与 NodeSecret / getToolConfigStatus 共用「尚未激活」判定。
if (isToolNode) {
const configStatus = getToolConfigStatus({ tool: data });
if (configStatus.status === 'waitingForConfig') {
addIssue({
node,
code: 'tool_waiting_config',
message: getWorkflowCheckIssueMessage('tool_waiting_config', t),
inputKey: NodeInputKeyEnum.systemInputConfig
});
}
}
if (!workflowCheckSkipNodeRuleTypes.has(data.flowNodeType)) {
if (data.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
const ifElseList = inputMap.get(NodeInputKeyEnum.ifElseList)?.value as
| IfElseListItemType[]
| undefined;
const hasIncompleteCondition = (ifElseList ?? []).some((item) =>
item.list.some(
(listItem) =>
listItem.variable === undefined ||
listItem.condition === undefined ||
(listItem.value === undefined &&
listItem.condition !== VariableConditionEnum.isEmpty &&
listItem.condition !== VariableConditionEnum.isNotEmpty)
)
);
if (!ifElseList || hasIncompleteCondition) {
addIssue({
node,
code: 'if_else_incomplete',
message: getWorkflowCheckIssueMessage('if_else_incomplete', t),
inputKey: NodeInputKeyEnum.ifElseList
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.userSelect) {
const configValue = inputMap.get(NodeInputKeyEnum.userSelectOptions)?.value as
| Array<{ value?: string }>
| undefined;
if (!configValue || configValue.length === 0) {
addIssue({
node,
code: 'user_select_empty',
message: getWorkflowCheckIssueMessage('user_select_empty', t),
inputKey: NodeInputKeyEnum.userSelectOptions
});
} else if (configValue.some((item) => !item.value)) {
addIssue({
node,
code: 'user_select_value_empty',
message: getWorkflowCheckIssueMessage('user_select_value_empty', t),
inputKey: NodeInputKeyEnum.userSelectOptions
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.formInput) {
const value = inputMap.get(NodeInputKeyEnum.userInputForms)?.value as unknown[] | undefined;
if (!value || value.length === 0) {
addIssue({
node,
code: 'form_input_empty',
message: getWorkflowCheckIssueMessage('form_input_empty', t),
inputKey: NodeInputKeyEnum.userInputForms
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.datasetConcatNode) {
const quoteInputs = inputs.filter((input) => input.canEdit);
if (quoteInputs.length === 0) {
addIssue({
node,
code: 'required_input_empty',
message: getWorkflowCheckIssueMessage('required_input_empty', t, {
inputName: t ? t('common:core.workflow.Dataset quote' as any) : '知识库引用'
}),
inputKey: NodeInputKeyEnum.datasetQuoteList
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.classifyQuestion) {
const agents = inputMap.get(NodeInputKeyEnum.agents)?.value as
| Array<{ value?: string; key?: string }>
| undefined;
if (!agents || agents.length === 0) {
addIssue({
node,
code: 'classify_question_empty',
message: getWorkflowCheckIssueMessage('classify_question_empty', t),
inputKey: NodeInputKeyEnum.agents
});
} else if (agents.some((item) => !item.value)) {
addIssue({
node,
code: 'classify_question_value_empty',
message: getWorkflowCheckIssueMessage('classify_question_value_empty', t),
inputKey: NodeInputKeyEnum.agents
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.code) {
const hasIncompleteDynamicInput = inputs.some((input) => {
if (
[
NodeInputKeyEnum.code,
NodeInputKeyEnum.codeType,
NodeInputKeyEnum.addInputParam
].includes(input.key as NodeInputKeyEnum)
) {
return false;
}
if (!input.canEdit) {
return false;
}
return !input.key || !input.label || isUnsetReferenceValue(input.value);
});
if (hasIncompleteDynamicInput) {
addIssue({
node,
code: 'code_input_incomplete',
message: getWorkflowCheckIssueMessage('code_input_incomplete', t)
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.httpRequest468) {
const urlInput = inputMap.get(NodeInputKeyEnum.httpReqUrl);
if (isEmptyWorkflowInputValue(urlInput?.value)) {
addIssue({
node,
code: 'http_url_empty',
message: getWorkflowCheckIssueMessage('http_url_empty', t),
inputKey: NodeInputKeyEnum.httpReqUrl
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.contentExtract) {
const extractKeys = inputMap.get(NodeInputKeyEnum.extractKeys)?.value as
| unknown[]
| undefined;
if (!extractKeys || extractKeys.length === 0) {
addIssue({
node,
code: 'context_extract_empty',
message: getWorkflowCheckIssueMessage('context_extract_empty', t),
inputKey: NodeInputKeyEnum.extractKeys
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.loopRun) {
const mode = inputMap.get(NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined;
if (mode === LoopRunModeEnum.conditional) {
const children =
(inputMap.get(NodeInputKeyEnum.childrenNodeIdList)?.value as string[]) ?? [];
const childSet = new Set(children);
const hasBreak = nodes.some(
(n) =>
childSet.has(n.data.nodeId) && n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
addIssue({
node,
code: 'loop_run_missing_break',
message: getWorkflowCheckIssueMessage('loop_run_missing_break', t)
});
}
}
}
if (data.flowNodeType === FlowNodeTypeEnum.toolCall) {
const toolConnections = context.outgoingEdgesMap
.get(data.nodeId)
?.filter((edge) => edge.sourceHandle === NodeOutputKeyEnum.selectedTools);
const useAgentSandbox = inputMap.get(NodeInputKeyEnum.useAgentSandbox)?.value;
if ((toolConnections?.length ?? 0) === 0 && !useAgentSandbox) {
addIssue({
node,
code: 'tool_call_empty',
message: getWorkflowCheckIssueMessage('tool_call_empty', t),
inputKey: NodeInputKeyEnum.useAgentSandbox
});
}
}
if (data.flowNodeType === FlowNodeTypeEnum.variableUpdate) {
const updateList = inputMap.get(NodeInputKeyEnum.updateList)?.value as
| TUpdateListItem[]
| undefined;
const addVariableUpdateRequiredIssue = (field: 'variable' | 'value') => {
const inputName = (() => {
if (field === 'variable') {
return t ? t('common:core.workflow.variable' as any) : '变量';
}
return t ? t('common:value' as any) : '值';
})();
addIssue({
node,
code: 'required_input_empty',
message: getWorkflowCheckIssueMessage('required_input_empty', t, {
inputName
}),
inputKey: NodeInputKeyEnum.updateList
});
};
if (!updateList || updateList.length === 0) {
addVariableUpdateRequiredIssue('variable');
addVariableUpdateRequiredIssue('value');
} else {
updateList.forEach((item) => {
if (isVariableUpdateTargetEmpty(item.variable, nodeIds, context)) {
addVariableUpdateRequiredIssue('variable');
}
if (isVariableUpdateValueEmpty(item, context)) {
addVariableUpdateRequiredIssue('value');
}
});
}
}
inputs.forEach((input) => {
if (input.key === NodeInputKeyEnum.loopRunInputArray) {
const loopRunMode = inputMap.get(NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined;
if (
data.flowNodeType === FlowNodeTypeEnum.loopRun &&
loopRunMode === LoopRunModeEnum.conditional
) {
return;
}
}
if (shouldSkipGenericRequiredInputCheck(input)) {
return;
}
if (isToolNode && input.toolDescription) {
return;
}
const isReferenceInput = nodeInputIsReference(input);
const isArrayReference = isReferenceInput && !!input.valueType?.startsWith('array');
const inputValueIsEmpty = isReferenceInput
? isEmptyReferenceInputValue(input.value, isArrayReference)
: isEmptyWorkflowInputValue(input.value);
if (
input.required &&
inputValueIsEmpty &&
!(data.flowNodeType === FlowNodeTypeEnum.code && input.canEdit)
) {
addIssue({
node,
code: 'required_input_empty',
message: getWorkflowCheckIssueMessage('required_input_empty', t, {
inputName: getInputLabel(input, t)
}),
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
});
}
}
});
}
if (!workflowCheckSkipConnectionTypes.has(data.flowNodeType)) {
const isStartNode = workflowCheckStartTypes.has(data.flowNodeType);
const incomingEdges = context.incomingEdgesMap.get(data.nodeId) ?? [];
const outgoingEdges = context.outgoingEdgesMap.get(data.nodeId) ?? [];
const meaningfulOutgoingEdges =
data.flowNodeType === FlowNodeTypeEnum.toolCall
? outgoingEdges.filter((edge) => edge.sourceHandle !== NodeOutputKeyEnum.selectedTools)
: outgoingEdges;
const hasAnyMeaningfulEdge = incomingEdges.length > 0 || meaningfulOutgoingEdges.length > 0;
if (!isStartNode && incomingEdges.length === 0) {
addIssue({
node,
code: 'no_upstream',
message: getWorkflowCheckIssueMessage('no_upstream', t)
});
} else if (!isStartNode && !context.reachableNodeSet.has(data.nodeId)) {
addIssue({
node,
code: 'unreachable_from_start',
message: getWorkflowCheckIssueMessage('unreachable_from_start', t)
});
} else if (!hasAnyMeaningfulEdge) {
addIssue({
node,
code: 'isolated_node',
message: getWorkflowCheckIssueMessage('isolated_node', t)
});
}
}
}
return issueMap;
};
export const checkWorkflowHasError = (nodeIssueMap: WorkflowCheckNodeIssueMap) =>
Object.values(nodeIssueMap).some((issues) => issues.some((issue) => issue.level === 'error'));
/** 返回存在 error 的 nodeId 列表;传入 nodeOrder 时按画布节点顺序排列,便于稳定定位第一个错误节点。 */
export const getWorkflowCheckErrorNodeIds = (
nodeIssueMap: WorkflowCheckNodeIssueMap,
nodeOrder?: string[]
) => {
const errorNodeIdSet = new Set(
Object.entries(nodeIssueMap)
.filter(([, issues]) => issues.some((issue) => issue.level === 'error'))
.map(([nodeId]) => nodeId)
);
if (nodeOrder) {
return nodeOrder.filter((nodeId) => errorNodeIdSet.has(nodeId));
}
return [...errorNodeIdSet];
};
/** 运行/发布前全量扫描,并按画布节点顺序返回第一个 error 节点。 */
export const checkWorkflowBeforeRunOrPublish = ({
nodes,
edges,
t
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
t?: TFunction;
}) => {
const issueMap = checkWorkflowNodeIssues({ nodes, edges, t });
const nodeOrder = nodes.map((node) => node.data.nodeId);
const errorNodeIds = getWorkflowCheckErrorNodeIds(issueMap, nodeOrder);
return {
issueMap,
hasError: errorNodeIds.length > 0,
firstErrorNodeId: errorNodeIds[0],
errorNodeIds
};
};
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { Edge, Node } from 'reactflow';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import type {
FlowNodeInputItemType,
FlowNodeOutputItemType,
ReferenceItemValueType,
ReferenceValueType
} from '@fastgpt/global/core/workflow/type/io';
import { isEqual } from 'lodash';
/** 引用输入是否尚未选择(空占位 / 未选变量),区别于曾经选中但已失效的引用。 */
const isUnsetReferenceValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return true;
if (!Array.isArray(value)) return true;
if (value.length === 0) return true;
// 单引用 [nodeId, outputId];占位符 ['', ''] 或格式不完整均视为未选择
if (value.length === 2 && !Array.isArray(value[0])) {
const [refNodeId, refOutputId] = value;
if (typeof refNodeId !== 'string') return true;
return !refNodeId || !refOutputId;
}
return false;
};
/** 根据流程开始节点输出,计算目标 input 的默认引用值;无匹配规则时返回 undefined。 */
const getWorkflowStartAutoFillValue = ({
inputKey,
workflowStartNodeId,
hasUserFilesOutput
}: {
inputKey: string;
workflowStartNodeId: string;
hasUserFilesOutput: boolean;
}): ReferenceValueType | undefined => {
if (inputKey === NodeInputKeyEnum.userChatInput) {
return [workflowStartNodeId, NodeOutputKeyEnum.userChatInput];
}
if (inputKey === NodeInputKeyEnum.datasetSearchInput) {
const refs: ReferenceItemValueType[] = [[workflowStartNodeId, NodeOutputKeyEnum.userChatInput]];
if (hasUserFilesOutput) {
refs.push([workflowStartNodeId, NodeOutputKeyEnum.userFiles]);
}
return refs;
}
if (inputKey === NodeInputKeyEnum.fileUrlList) {
if (!hasUserFilesOutput) return undefined;
return [[workflowStartNodeId, NodeOutputKeyEnum.userFiles]];
}
return undefined;
};
/**
* 为空白引用输入自动填充「流程开始」上游输出引用。
* 仅处理引用类输入且 value 尚未配置的场景,不覆盖已有合法手动引用。
*/
export const applyWorkflowStartInputAutoFill = ({
inputs,
workflowStartNodeId,
workflowStartOutputs
}: {
inputs: FlowNodeInputItemType[];
workflowStartNodeId: string;
workflowStartOutputs: FlowNodeOutputItemType[];
}): FlowNodeInputItemType[] => {
const hasUserFilesOutput = workflowStartOutputs.some(
(output) => output.id === NodeOutputKeyEnum.userFiles
);
return inputs.map((input) => {
if (!nodeInputIsReference(input) || !isUnsetReferenceValue(input.value)) {
return input;
}
const autoFillValue = getWorkflowStartAutoFillValue({
inputKey: input.key,
workflowStartNodeId,
hasUserFilesOutput
});
if (autoFillValue === undefined) {
return input;
}
return {
...input,
value: autoFillValue
};
});
};
type WorkflowStartAutoFillPatch = {
nodeId: string;
key: string;
value: FlowNodeInputItemType;
};
const collectWorkflowReachableNodeIds = ({
startNodeId,
edges
}: {
startNodeId: string;
edges: Array<Pick<Edge, 'source' | 'target'>>;
}) => {
const reachableNodeIds = new Set<string>();
const queue = [startNodeId];
while (queue.length > 0) {
const sourceNodeId = queue.shift();
if (!sourceNodeId) continue;
edges.forEach((edge) => {
if (edge.source !== sourceNodeId || reachableNodeIds.has(edge.target)) return;
reachableNodeIds.add(edge.target);
queue.push(edge.target);
});
}
return reachableNodeIds;
};
/**
* 收集从流程开始节点可达的下游节点自动填充补丁。
* 文件上传等系统输入可能在连线之后才开启,因此这里按当前 edge 图整体扫描,
* 只补空白引用输入,避免覆盖用户已经手动选择的变量。
*/
export const collectWorkflowStartInputAutoFillPatches = ({
nodes,
edges,
workflowStartNode
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Array<Pick<Edge, 'source' | 'target'>>;
workflowStartNode: FlowNodeItemType;
}): WorkflowStartAutoFillPatch[] => {
const nodeMap = new Map(nodes.map((node) => [node.data.nodeId, node.data]));
const reachableNodeIds = collectWorkflowReachableNodeIds({
startNodeId: workflowStartNode.nodeId,
edges
});
const patches: WorkflowStartAutoFillPatch[] = [];
reachableNodeIds.forEach((nodeId) => {
const targetNode = nodeMap.get(nodeId);
if (!targetNode) return;
const nextInputs = applyWorkflowStartInputAutoFill({
inputs: targetNode.inputs,
workflowStartNodeId: workflowStartNode.nodeId,
workflowStartOutputs: workflowStartNode.outputs
});
nextInputs.forEach((input) => {
const prevInput = targetNode.inputs.find((item) => item.key === input.key);
if (prevInput && !isEqual(prevInput.value, input.value)) {
patches.push({
nodeId: targetNode.nodeId,
key: input.key,
value: input
});
}
});
});
return patches;
};
/** 判断 input.value 是否为流程开始自动填充产生的引用,用于断线回滚时避免误清手动配置。 */
const isWorkflowStartAutoFilledValue = ({
inputKey,
value,
workflowStartNodeId,
hasUserFilesOutput
}: {
inputKey: string;
value: unknown;
workflowStartNodeId: string;
hasUserFilesOutput: boolean;
}) => {
const autoFillValue = getWorkflowStartAutoFillValue({
inputKey,
workflowStartNodeId,
hasUserFilesOutput
});
if (autoFillValue === undefined) return false;
return isEqual(value, autoFillValue);
};
/**
* 断开与流程开始的连线后,回滚此前自动写入的引用值。
* 仅清除与自动填充结果完全一致的 value,保留手动配置或其他上游引用。
*/
export const revertWorkflowStartInputAutoFill = ({
inputs,
workflowStartNodeId,
workflowStartOutputs
}: {
inputs: FlowNodeInputItemType[];
workflowStartNodeId: string;
workflowStartOutputs: FlowNodeOutputItemType[];
}): FlowNodeInputItemType[] => {
const hasUserFilesOutput = workflowStartOutputs.some(
(output) => output.id === NodeOutputKeyEnum.userFiles
);
return inputs.map((input) => {
if (
!isWorkflowStartAutoFilledValue({
inputKey: input.key,
value: input.value,
workflowStartNodeId,
hasUserFilesOutput
})
) {
return input;
}
return {
...input,
value: undefined
};
});
};
/**
* 根据被删除的连线,收集需要回滚的流程开始自动填充补丁。
* 任意位置断线都可能让部分下游节点失去流程开始可达性,因此这里比较删除前后
* 每个流程开始节点的可达集合,只清理「删除前可达、删除后不可达」节点上的自动填充值。
*/
export const collectWorkflowStartAutoFillRevertPatches = ({
removedEdges,
remainingEdges,
getNodeById
}: {
removedEdges: Array<Pick<Edge, 'id' | 'source' | 'target'>>;
remainingEdges: Array<Pick<Edge, 'id' | 'source' | 'target'>>;
getNodeById: (nodeId: string) => FlowNodeItemType | undefined;
}): Array<{ nodeId: string; key: string; value: FlowNodeInputItemType }> => {
const patches: Array<{ nodeId: string; key: string; value: FlowNodeInputItemType }> = [];
const processedNodes = new Set<string>();
const previousEdges = remainingEdges.concat(removedEdges);
const workflowStartNodes = Array.from(
new Map(
previousEdges
.map((edge) => getNodeById(edge.source))
.filter((node): node is FlowNodeItemType => {
return node?.flowNodeType === FlowNodeTypeEnum.workflowStart;
})
.map((node) => [node.nodeId, node])
).values()
);
workflowStartNodes.forEach((sourceNode) => {
const previousReachableNodeIds = collectWorkflowReachableNodeIds({
startNodeId: sourceNode.nodeId,
edges: previousEdges
});
const nextReachableNodeIds = collectWorkflowReachableNodeIds({
startNodeId: sourceNode.nodeId,
edges: remainingEdges
});
previousReachableNodeIds.forEach((nodeId) => {
if (nextReachableNodeIds.has(nodeId) || processedNodes.has(nodeId)) return;
const targetNode = getNodeById(nodeId);
if (!targetNode) return;
processedNodes.add(nodeId);
const nextInputs = revertWorkflowStartInputAutoFill({
inputs: targetNode.inputs,
workflowStartNodeId: sourceNode.nodeId,
workflowStartOutputs: sourceNode.outputs
});
nextInputs.forEach((input) => {
const prevInput = targetNode.inputs.find((item) => item.key === input.key);
if (prevInput && !isEqual(prevInput.value, input.value)) {
patches.push({
nodeId: targetNode.nodeId,
key: input.key,
value: input
});
}
});
});
});
return patches;
};
/**
* 删除流程开始节点的某个输出前,回滚由自动填充写入的对应引用。
* 保留仍然有效的自动填充引用,例如关闭文件上传后 datasetSearchInput 仍保留用户问题引用。
*/
export const collectWorkflowStartOutputAutoFillRevertPatches = ({
nodes,
edges,
workflowStartNode,
outputKey
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Array<Pick<Edge, 'source' | 'target'>>;
workflowStartNode: FlowNodeItemType;
outputKey: string;
}): WorkflowStartAutoFillPatch[] => {
if (outputKey !== NodeOutputKeyEnum.userFiles) return [];
const nodeMap = new Map(nodes.map((node) => [node.data.nodeId, node.data]));
const reachableNodeIds = collectWorkflowReachableNodeIds({
startNodeId: workflowStartNode.nodeId,
edges
});
const nextWorkflowStartOutputs = workflowStartNode.outputs.filter(
(output) => output.key !== outputKey && output.id !== outputKey
);
const patches: WorkflowStartAutoFillPatch[] = [];
reachableNodeIds.forEach((nodeId) => {
const targetNode = nodeMap.get(nodeId);
if (!targetNode) return;
targetNode.inputs.forEach((input) => {
if (
!isWorkflowStartAutoFilledValue({
inputKey: input.key,
value: input.value,
workflowStartNodeId: workflowStartNode.nodeId,
hasUserFilesOutput: true
})
) {
return;
}
const nextValue = getWorkflowStartAutoFillValue({
inputKey: input.key,
workflowStartNodeId: workflowStartNode.nodeId,
hasUserFilesOutput: nextWorkflowStartOutputs.some(
(output) => output.id === NodeOutputKeyEnum.userFiles
)
});
if (isEqual(input.value, nextValue)) return;
patches.push({
nodeId: targetNode.nodeId,
key: input.key,
value: {
...input,
value: nextValue
}
});
});
});
return patches;
};
...@@ -21,128 +21,1562 @@ import { ...@@ -21,128 +21,1562 @@ import {
getNodeAllSource, getNodeAllSource,
filterWorkflowNodeOutputsByType, filterWorkflowNodeOutputsByType,
filterSelectableWorkflowNodeOutputs, filterSelectableWorkflowNodeOutputs,
workflowReferenceValueIsSelectable, workflowReferenceValueIsSelectable
checkWorkflowNodeAndConnection
} from '@/web/core/workflow/utils'; } from '@/web/core/workflow/utils';
import {
checkWorkflowNodeIssues,
checkWorkflowHasError,
checkWorkflowBeforeRunOrPublish,
getWorkflowCheckErrorNodeIds
} from '@/web/core/workflow/workflowCheck';
import {
applyWorkflowStartInputAutoFill,
collectWorkflowStartInputAutoFillPatches,
collectWorkflowStartAutoFillRevertPatches,
collectWorkflowStartOutputAutoFillRevertPatches
} from '@/web/core/workflow/workflowStartAutoFill';
import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io';
import { NodeOutputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type';
import { AppErrEnum } from '@fastgpt/global/common/error/code/app';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
import { AssignedAnswerModule } from '@fastgpt/global/core/workflow/template/system/assignedAnswer';
import {
DatasetConcatModule,
getOneQuoteInputTemplate
} from '@fastgpt/global/core/workflow/template/system/datasetConcat';
import { HttpNode468 } from '@fastgpt/global/core/workflow/template/system/http468';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiChat';
import { DatasetSearchModule } from '@fastgpt/global/core/workflow/template/system/datasetSearch';
import { ClassifyQuestionModule } from '@fastgpt/global/core/workflow/template/system/classifyQuestion';
import { ToolCallNode } from '@fastgpt/global/core/workflow/template/system/toolCall';
import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/workflowStart';
describe('nodeTemplate2FlowNode', () => {
it('should initialize template text once before formatting the instance name', () => {
const template: FlowNodeTemplateType = {
id: 'template1',
templateType: 'formInput',
name: 'workflow:template_name',
intro: 'workflow:template_intro',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [],
outputs: []
};
const t = vi.fn(
(key: string) =>
({
'workflow:template_name': 'Template Name',
'workflow:template_intro': 'Template Intro'
})[key] ?? key
);
const result = nodeTemplate2FlowNode({
template,
position: { x: 100, y: 100 },
selected: true,
parentNodeId: 'parent1',
t: t as any,
formatName: (name) => `${name} 2`
});
expect(result).toMatchObject({
type: FlowNodeTypeEnum.formInput,
position: { x: 100, y: 100 },
selected: true,
data: {
name: 'Template Name 2',
intro: 'Template Intro',
flowNodeType: FlowNodeTypeEnum.formInput,
parentNodeId: 'parent1'
}
});
expect(result.id).toBeDefined();
expect(t.mock.calls.map(([key]) => key)).toEqual([
'workflow:template_name',
'workflow:template_intro'
]);
});
});
describe('adaptStoreNodeInputs', () => {
const createAgentNode = (inputs: StoreNodeItemType['inputs']): StoreNodeItemType => ({
nodeId: 'agent-node',
flowNodeType: FlowNodeTypeEnum.agent,
name: 'Agent',
inputs,
outputs: []
});
it('should reset legacy Agent resource references to manual selection', () => {
const inputs: StoreNodeItemType['inputs'] = [
{
key: NodeInputKeyEnum.skills,
label: 'Skills',
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: ['source-node', 'skills']
},
{
key: NodeInputKeyEnum.selectedTools,
label: 'Tools',
renderTypeList: [FlowNodeInputTypeEnum.selectTool, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: ['source-node', 'tools']
},
{
key: NodeInputKeyEnum.datasetSelectList,
label: 'Datasets',
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: ['source-node', 'datasets']
}
];
const result = adaptStoreNodeInputs(createAgentNode(inputs));
expect(result).toEqual(
inputs.map((input) => ({
...input,
selectedTypeIndex: 0,
value: []
}))
);
});
it('should preserve manually selected Agent resources and unrelated inputs', () => {
const selectedSkills = [{ skillId: 'skill-1', name: 'Skill 1' }];
const promptReference = ['source-node', 'prompt'];
const inputs: StoreNodeItemType['inputs'] = [
{
key: NodeInputKeyEnum.skills,
label: 'Skills',
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: selectedSkills
},
{
key: NodeInputKeyEnum.aiSystemPrompt,
label: 'Prompt',
renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: promptReference
}
];
const result = adaptStoreNodeInputs(createAgentNode(inputs));
expect(result[0]).toMatchObject({ selectedTypeIndex: 0, value: selectedSkills });
expect(result[1]).toBe(inputs[1]);
});
});
describe('checkWorkflowNodeIssues', () => {
const makeNode = (
nodeId: string,
flowNodeType: FlowNodeTypeEnum,
data?: Partial<FlowNodeItemType>
): Node<FlowNodeItemType> =>
({
id: nodeId,
type: flowNodeType,
position: { x: 0, y: 0 },
data: {
nodeId,
flowNodeType,
name: nodeId,
inputs: [],
outputs: [],
...data
}
}) as Node<FlowNodeItemType>;
const startNode = makeNode('start', FlowNodeTypeEnum.workflowStart, {
outputs: [
{
id: NodeOutputKeyEnum.userChatInput,
key: NodeOutputKeyEnum.userChatInput,
label: 'question',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.string
}
]
});
it('collects multiple node errors in one pass', () => {
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
]
});
const formNode = makeNode('form', FlowNodeTypeEnum.formInput, {
inputs: [
{
key: NodeInputKeyEnum.userInputForms,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: []
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, requiredNode, formNode],
edges: [
{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'form', type: EDGE_TYPE }
]
});
expect(Object.keys(result).sort()).toEqual(['form', 'required']);
expect(result.required.map((issue) => issue.code)).toContain('required_input_empty');
expect(result.form.map((issue) => issue.code)).toContain('form_input_empty');
});
it('keeps all errors on a single node', () => {
const node = makeNode('multi', FlowNodeTypeEnum.userSelect, {
inputs: [
{
key: NodeInputKeyEnum.userSelectOptions,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: [{ value: '' }]
},
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'multi', type: EDGE_TYPE }]
});
expect(result.multi.map((issue) => issue.code)).toEqual(
expect.arrayContaining(['user_select_value_empty', 'required_input_empty'])
);
});
it('reports nodes without upstream connections', () => {
const node = makeNode('orphan', FlowNodeTypeEnum.answerNode);
const result = checkWorkflowNodeIssues({ nodes: [startNode, node], edges: [] });
expect(result.orphan.map((issue) => issue.code)).toContain('no_upstream');
});
it('reports invalid references', () => {
const node = makeNode('ref', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: ['deleted', 'output']
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }]
});
expect(result.ref.map((issue) => issue.code)).toContain('invalid_reference');
});
it('reports generic plugin load errors without telling users to delete the tool', () => {
const node = makeNode('tool', FlowNodeTypeEnum.appModule, {
pluginData: {
error: 'not found'
} as any
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }]
});
expect(result.tool.map((issue) => issue.code)).toContain('tool_load_failed');
expect(result.tool[0]?.message).toBe('工具加载失败,请稍后重试');
});
it('reports permission error when pluginData error is unAuthApp', () => {
const node = makeNode('tool', FlowNodeTypeEnum.appModule, {
pluginData: {
error: AppErrEnum.unAuthApp
} as any
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }]
});
expect(result.tool.map((issue) => issue.code)).toContain('tool_no_permission');
expect(result.tool[0]?.message).toBe('当前账号无权限访问该资源');
});
it('reports permission error when pluginData error is translated message', () => {
const node = makeNode('tool', FlowNodeTypeEnum.pluginModule, {
pluginData: {
error: ERROR_RESPONSE[PluginErrEnum.unAuth].message
} as any
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }]
});
expect(result.tool.map((issue) => issue.code)).toContain('tool_no_permission');
expect(result.tool[0]?.message).toBe('当前账号无权限访问该资源');
});
it('reports missing tool when pluginData error is appUnExist', () => {
const node = makeNode('tool', FlowNodeTypeEnum.runApp, {
pluginData: {
error: AppErrEnum.unExist
} as any
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }]
});
expect(result.tool.map((issue) => issue.code)).toContain('tool_missing');
});
it('uses fixed design copy for mapped issue codes', () => {
const disconnectedNode = makeNode('disconnected', FlowNodeTypeEnum.answerNode);
const unreachableNode = makeNode('unreachable', FlowNodeTypeEnum.answerNode);
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
]
});
const unreachableResult = checkWorkflowNodeIssues({
nodes: [startNode, disconnectedNode, unreachableNode],
edges: [{ id: 'e1', source: 'disconnected', target: 'unreachable', type: EDGE_TYPE }]
});
const requiredResult = checkWorkflowNodeIssues({
nodes: [startNode, requiredNode],
edges: [{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }]
});
expect(unreachableResult.unreachable[0]?.code).toBe('unreachable_from_start');
expect(unreachableResult.unreachable[0]?.message).toBe('未与其他节点连线');
expect(requiredResult.required[0]?.message).toBe('需填写必填项 answer');
});
it('does not treat non-latest tool versions as inactive', () => {
const nonLatestNode = makeNode('non-latest', FlowNodeTypeEnum.appModule, {
pluginData: {} as any,
isLatestVersion: false
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, nonLatestNode],
edges: [{ id: 'e1', source: 'start', target: 'non-latest', type: EDGE_TYPE }]
});
expect(result['non-latest']?.map((issue) => issue.code) ?? []).not.toContain('tool_inactive');
});
it('reports offline tools', () => {
const offlineNode = makeNode('offline', FlowNodeTypeEnum.appModule, {
status: PluginStatusEnum.Offline
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, offlineNode],
edges: [{ id: 'e1', source: 'start', target: 'offline', type: EDGE_TYPE }]
});
expect(result.offline.map((issue) => issue.code)).toContain('tool_offline');
});
it('reports specific node configuration errors', () => {
const httpNode = makeNode('http', FlowNodeTypeEnum.httpRequest468, {
inputs: [
{
key: NodeInputKeyEnum.httpReqUrl,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
]
});
const toolCallNode = makeNode('toolCall', FlowNodeTypeEnum.toolCall, {
inputs: [
{
key: NodeInputKeyEnum.useAgentSandbox,
renderTypeList: [FlowNodeInputTypeEnum.switch],
value: false
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, httpNode, toolCallNode],
edges: [
{ id: 'e1', source: 'start', target: 'http', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'toolCall', type: EDGE_TYPE }
]
});
expect(result.http.map((issue) => issue.code)).toContain('http_url_empty');
expect(result.toolCall.map((issue) => issue.code)).toContain('tool_call_empty');
});
/**
* 使用 packages/global 真实节点模板构造 inputs,避免手写 valueType 掩盖模板默认值。
*/
describe('real node template default validation', () => {
const makeNodeWithTemplateInputs = (
nodeId: string,
flowNodeType: FlowNodeTypeEnum,
templateInputs: FlowNodeTemplateType['inputs']
) =>
makeNode(nodeId, flowNodeType, {
inputs: templateInputs.map((input) => ({ ...input }))
});
const runCheck = (node: Node<FlowNodeItemType>, extraNodes: Node<FlowNodeItemType>[] = []) =>
checkWorkflowNodeIssues({
nodes: [startNode, ...extraNodes, node],
edges: [
{ id: 'e-start', source: 'start', target: node.data.nodeId, type: EDGE_TYPE },
...extraNodes.map((extraNode, index) => ({
id: `e-extra-${index}`,
source: 'start',
target: extraNode.data.nodeId,
type: EDGE_TYPE
}))
]
});
it('AssignedAnswerModule keeps required answerText with valueType any (template contract)', () => {
const answerInput = AssignedAnswerModule.inputs.find(
(input) => input.key === NodeInputKeyEnum.answerText
);
expect(answerInput?.required).toBe(true);
expect(answerInput?.valueType).toBe(WorkflowIOValueTypeEnum.any);
expect(answerInput?.value).toBeUndefined();
});
it('answerNode template default: empty required answerText reports required_input_empty', () => {
const node = makeNodeWithTemplateInputs(
'answer',
FlowNodeTypeEnum.answerNode,
AssignedAnswerModule.inputs
);
const result = runCheck(node);
const answerIssues =
result.answer?.filter((issue) => issue.inputKey === NodeInputKeyEnum.answerText) ?? [];
expect(answerIssues.map((issue) => issue.code)).toContain('required_input_empty');
});
it('answerNode with filled content does not report required_input_empty', () => {
const node = makeNodeWithTemplateInputs('answer-ok', FlowNodeTypeEnum.answerNode, [
{
...AssignedAnswerModule.inputs[0],
value: 'hello'
}
]);
const result = runCheck(node);
const answerIssues =
result['answer-ok']?.filter((issue) => issue.code === 'required_input_empty') ?? [];
expect(answerIssues).toEqual([]);
});
it('answerNode contrast: same field with valueType string reports required_input_empty', () => {
const templateInput = AssignedAnswerModule.inputs.find(
(input) => input.key === NodeInputKeyEnum.answerText
);
expect(templateInput).toBeDefined();
const node = makeNode('answer-string', FlowNodeTypeEnum.answerNode, {
inputs: [
{
...templateInput!,
valueType: WorkflowIOValueTypeEnum.string,
value: ''
}
]
});
const result = runCheck(node);
const answerIssues =
result['answer-string']?.filter(
(issue) => issue.inputKey === NodeInputKeyEnum.answerText
) ?? [];
expect(answerIssues.map((issue) => issue.code)).toContain('required_input_empty');
});
it('DatasetConcatModule default: empty quote list reports required_input_empty', () => {
const node = makeNodeWithTemplateInputs(
'concat',
FlowNodeTypeEnum.datasetConcatNode,
DatasetConcatModule.inputs
);
expect(node.data.inputs.filter((input) => input.canEdit)).toHaveLength(0);
const result = runCheck(node);
expect(result.concat?.map((issue) => issue.code)).toContain('required_input_empty');
expect(result.concat?.[0]?.inputKey).toBe(NodeInputKeyEnum.datasetQuoteList);
});
it('datasetConcat added quote without reference reports required_input_empty', () => {
const quoteTemplate = getOneQuoteInputTemplate({ index: 1 });
const node = makeNodeWithTemplateInputs('concat-one', FlowNodeTypeEnum.datasetConcatNode, [
...DatasetConcatModule.inputs,
quoteTemplate
]);
expect(quoteTemplate.required).toBe(true);
const result = runCheck(node);
const quoteIssues =
result['concat-one']?.filter((issue) => issue.code === 'required_input_empty') ?? [];
expect(quoteIssues.length).toBeGreaterThan(0);
});
it('datasetConcat with valid quote reference does not report required_input_empty', () => {
const datasetNode = makeNode('dataset', FlowNodeTypeEnum.datasetSearchNode, {
outputs: [
{
id: NodeOutputKeyEnum.datasetQuoteQA,
key: NodeOutputKeyEnum.datasetQuoteQA,
label: 'quote',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.datasetQuote
}
]
});
const quoteKey = 'quote-1';
const node = makeNodeWithTemplateInputs('concat-ok', FlowNodeTypeEnum.datasetConcatNode, [
...DatasetConcatModule.inputs,
{
...getOneQuoteInputTemplate({ key: quoteKey, index: 1 }),
value: ['dataset', NodeOutputKeyEnum.datasetQuoteQA]
}
]);
const result = checkWorkflowNodeIssues({
nodes: [startNode, datasetNode, node],
edges: [
{ id: 'e1', source: 'start', target: 'dataset', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'concat-ok', type: EDGE_TYPE }
]
});
const requiredIssues =
result['concat-ok']?.filter((issue) => issue.code === 'required_input_empty') ?? [];
expect(requiredIssues).toEqual([]);
});
it('HttpNode468 template default: empty httpReqUrl reports http_url_empty via node rule', () => {
const node = makeNodeWithTemplateInputs(
'http',
FlowNodeTypeEnum.httpRequest468,
HttpNode468.inputs
);
const urlInput = node.data.inputs.find((input) => input.key === NodeInputKeyEnum.httpReqUrl);
expect(urlInput?.required).toBe(true);
expect(urlInput?.value).toBeUndefined();
const result = runCheck(node);
expect(result.http?.map((issue) => issue.code)).toContain('http_url_empty');
expect(result.http?.[0]?.inputKey).toBe(NodeInputKeyEnum.httpReqUrl);
expect(result.http?.map((issue) => issue.code)).not.toContain('required_input_empty');
});
it('HttpNode468 with request url does not report http_url_empty', () => {
const node = makeNodeWithTemplateInputs(
'http-ok',
FlowNodeTypeEnum.httpRequest468,
HttpNode468.inputs.map((input) =>
input.key === NodeInputKeyEnum.httpReqUrl
? { ...input, value: 'https://example.com/api' }
: input
)
);
const result = runCheck(node);
expect(result['http-ok']?.map((issue) => issue.code) ?? []).not.toContain('http_url_empty');
});
it('loopStart hidden required any does not false-positive required_input_empty', () => {
const node = makeNodeWithTemplateInputs(
'loop-start',
FlowNodeTypeEnum.nestedStart,
LoopStartNode.inputs
);
const result = runCheck(node);
expect(result['loop-start']?.map((issue) => issue.code) ?? []).not.toContain(
'required_input_empty'
);
});
});
it('returns invalid reference message with input name', () => {
const node = makeNode('ref', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: ['deleted', 'output']
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }]
});
expect(result.ref[0]?.message).toBe('answer 引用了无效变量,需删除');
});
it('treats unset reference as required_input_empty instead of invalid_reference', () => {
const unsetValues = [undefined, ['', ''], [undefined, undefined]] as const;
unsetValues.forEach((value, index) => {
const node = makeNode(`empty-ref-${index}`, FlowNodeTypeEnum.chatNode, {
inputs: [
{
key: NodeInputKeyEnum.userChatInput,
label: '用户问题',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, node],
edges: [{ id: `e-${index}`, source: 'start', target: node.id, type: EDGE_TYPE }]
});
const issueCodes = result[node.id]?.map((issue) => issue.code) ?? [];
expect(issueCodes).toContain('required_input_empty');
expect(issueCodes).not.toContain('invalid_reference');
});
});
describe('auto-fill input variables after connecting from workflow start', () => {
const makeFreshConnectedNode = (
nodeId: string,
template: FlowNodeTemplateType
): Node<FlowNodeItemType> =>
makeNode(nodeId, template.flowNodeType, {
inputs: template.inputs.map((input) => ({
...input,
value: input.value ?? input.defaultValue
})),
outputs: template.outputs
});
const startNodeWithFiles = makeNode('start', FlowNodeTypeEnum.workflowStart, {
outputs: [...startNode.data.outputs, userFilesInput]
});
const applyStartAutoFill = (targetNode: Node<FlowNodeItemType>, workflowStart = startNode) => {
targetNode.data.inputs = applyWorkflowStartInputAutoFill({
inputs: targetNode.data.inputs,
workflowStartNodeId: workflowStart.data.nodeId,
workflowStartOutputs: workflowStart.data.outputs
});
};
const expectInputValue = (
node: Node<FlowNodeItemType>,
inputKey: NodeInputKeyEnum,
expectedValue: unknown
) => {
expect(
node.data.inputs.find((input) => input.key === inputKey)?.value,
`${node.data.name || node.id}.${inputKey} should auto reference workflow start output after connection`
).toEqual(expectedValue);
};
it.each([
[
'AI 对话',
AiChatModule,
'ai-chat',
NodeInputKeyEnum.userChatInput,
['start', NodeOutputKeyEnum.userChatInput]
],
[
'知识库搜索',
DatasetSearchModule,
'dataset-search',
NodeInputKeyEnum.datasetSearchInput,
[['start', NodeOutputKeyEnum.userChatInput]]
],
[
'问题分类',
ClassifyQuestionModule,
'classify',
NodeInputKeyEnum.userChatInput,
['start', NodeOutputKeyEnum.userChatInput]
],
[
'工具调用',
ToolCallNode,
'tool-call',
NodeInputKeyEnum.userChatInput,
['start', NodeOutputKeyEnum.userChatInput]
]
] as const)(
'%s 节点连线后应自动填充用户问题引用',
(_nodeName, template, nodeId, inputKey, expectedValue) => {
const targetNode = makeFreshConnectedNode(nodeId, template);
applyStartAutoFill(targetNode);
const result = checkWorkflowNodeIssues({
nodes: [startNode, targetNode],
edges: [{ id: `e-start-${nodeId}`, source: 'start', target: nodeId, type: EDGE_TYPE }]
});
expectInputValue(targetNode, inputKey, expectedValue);
expect(
result[nodeId]
?.filter((issue) => issue.inputKey === inputKey)
.map((issue) => issue.code) ?? []
).not.toContain('required_input_empty');
}
);
it('AI 对话节点连线且开启文件上传后应自动填充文件链接引用', () => {
const targetNode = makeFreshConnectedNode('ai-chat', AiChatModule);
applyStartAutoFill(targetNode, startNodeWithFiles);
expectInputValue(targetNode, NodeInputKeyEnum.fileUrlList, [
['start', NodeOutputKeyEnum.userFiles]
]);
expect(
workflowReferenceValueIsSelectable({
value: targetNode.data.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)
?.value as any,
sourceNodes: [
{
nodeId: startNodeWithFiles.data.nodeId,
outputs: startNodeWithFiles.data.outputs
}
],
valueType: WorkflowIOValueTypeEnum.arrayString
})
).toBe(true);
const result = checkWorkflowNodeIssues({
nodes: [startNodeWithFiles, targetNode],
edges: [{ id: 'e-start-ai-chat', source: 'start', target: 'ai-chat', type: EDGE_TYPE }]
});
const fileLinkIssues =
result['ai-chat']?.filter((issue) => issue.inputKey === NodeInputKeyEnum.fileUrlList) ?? [];
expect(fileLinkIssues).toEqual([]);
});
it('收集自动填充补丁时,同一个节点的文件链接和用户问题应同时返回', () => {
const targetNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const patches = collectWorkflowStartInputAutoFillPatches({
nodes: [startNodeWithFiles, targetNode],
edges: [{ id: 'e-start-ai-chat', source: 'start', target: 'ai-chat', type: EDGE_TYPE }],
workflowStartNode: startNodeWithFiles.data
});
expect(
patches
.filter((patch) => patch.nodeId === 'ai-chat')
.map((patch) => patch.key)
.sort()
).toEqual([NodeInputKeyEnum.fileUrlList, NodeInputKeyEnum.userChatInput].sort());
});
it('开启文件上传后再关闭,应清理自动写入的 userFiles 引用', () => {
const aiNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const datasetNode = makeFreshConnectedNode('dataset-search', DatasetSearchModule);
const edges: Edge[] = [
{ id: 'e-start-ai', source: 'start', target: 'ai-chat', type: EDGE_TYPE },
{ id: 'e-ai-dataset', source: 'ai-chat', target: 'dataset-search', type: EDGE_TYPE }
];
const nodes = [startNodeWithFiles, aiNode, datasetNode];
const autoFillPatches = collectWorkflowStartInputAutoFillPatches({
nodes,
edges,
workflowStartNode: startNodeWithFiles.data
});
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = autoFillPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
const revertPatches = collectWorkflowStartOutputAutoFillRevertPatches({
nodes,
edges,
workflowStartNode: startNodeWithFiles.data,
outputKey: userFilesInput.key
});
expect(revertPatches.map((patch) => `${patch.nodeId}:${patch.key}`).sort()).toEqual(
[
`ai-chat:${NodeInputKeyEnum.fileUrlList}`,
`dataset-search:${NodeInputKeyEnum.datasetSearchInput}`
].sort()
);
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = revertPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
expectInputValue(aiNode, NodeInputKeyEnum.fileUrlList, undefined);
expectInputValue(datasetNode, NodeInputKeyEnum.datasetSearchInput, [
['start', NodeOutputKeyEnum.userChatInput]
]);
const result = checkWorkflowNodeIssues({
nodes: [startNode, aiNode, datasetNode],
edges
});
expect(
result['ai-chat']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.fileUrlList)
.map((issue) => issue.code) ?? []
).not.toContain('invalid_reference');
expect(
result['dataset-search']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.datasetSearchInput)
.map((issue) => issue.code) ?? []
).not.toContain('invalid_reference');
});
it('流程开始节点可达的间接下游节点也应自动填充用户问题引用', () => {
const aiNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const toolNode = makeFreshConnectedNode('tool-call', ToolCallNode);
const patches = collectWorkflowStartInputAutoFillPatches({
nodes: [startNode, aiNode, toolNode],
edges: [
{ id: 'e-start-ai', source: 'start', target: 'ai-chat', type: EDGE_TYPE },
{ id: 'e-ai-tool', source: 'ai-chat', target: 'tool-call', type: EDGE_TYPE }
],
workflowStartNode: startNode.data
});
const toolUserQuestionPatch = patches.find(
(patch) => patch.nodeId === 'tool-call' && patch.key === NodeInputKeyEnum.userChatInput
);
expect(toolUserQuestionPatch?.value.value).toEqual([
'start',
NodeOutputKeyEnum.userChatInput
]);
});
it('合法手动配置的用户问题引用不应被连线自动填充覆盖', () => {
const targetNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const manualReference = [VARIABLE_NODE_ID, 'customQuestion'];
targetNode.data.inputs = targetNode.data.inputs.map((input) =>
input.key === NodeInputKeyEnum.userChatInput ? { ...input, value: manualReference } : input
);
applyStartAutoFill(targetNode);
expectInputValue(targetNode, NodeInputKeyEnum.userChatInput, manualReference);
});
it('未从流程开始连线时不应自动填充,应提示用户问题必填', () => {
const targetNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const result = checkWorkflowNodeIssues({
nodes: [startNode, targetNode],
edges: []
});
expectInputValue(targetNode, NodeInputKeyEnum.userChatInput, undefined);
expect(
result['ai-chat']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
});
it('断开流程开始连线后应回滚自动填充并重新提示用户问题必填', () => {
const targetNode = makeFreshConnectedNode('ai-chat', AiChatModule);
applyStartAutoFill(targetNode);
const patches = collectWorkflowStartAutoFillRevertPatches({
removedEdges: [{ id: 'e1', source: 'start', target: 'ai-chat' }],
remainingEdges: [],
getNodeById: (nodeId) => {
if (nodeId === 'start') return startNode.data;
if (nodeId === 'ai-chat') return targetNode.data;
return undefined;
}
});
expect(patches.map((patch) => patch.key)).toContain(NodeInputKeyEnum.userChatInput);
targetNode.data.inputs = targetNode.data.inputs.map((input) => {
const patch = patches.find((item) => item.key === input.key);
return patch ? patch.value : input;
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, targetNode],
edges: []
});
expectInputValue(targetNode, NodeInputKeyEnum.userChatInput, undefined);
expect(
result['ai-chat']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
});
it('断开流程开始主链路后应回滚整条下游链的自动填充并恢复必填校验', () => {
const aiNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const datasetNode = makeFreshConnectedNode('dataset-search', DatasetSearchModule);
const classifyNode = makeFreshConnectedNode('classify', ClassifyQuestionModule);
const toolNode = makeFreshConnectedNode('tool-call', ToolCallNode);
const nodes = [startNodeWithFiles, aiNode, datasetNode, classifyNode, toolNode];
const previousEdges: Edge[] = [
{ id: 'e-start-ai', source: 'start', target: 'ai-chat', type: EDGE_TYPE },
{ id: 'e-ai-dataset', source: 'ai-chat', target: 'dataset-search', type: EDGE_TYPE },
{ id: 'e-ai-classify', source: 'ai-chat', target: 'classify', type: EDGE_TYPE },
{ id: 'e-classify-tool', source: 'classify', target: 'tool-call', type: EDGE_TYPE }
];
const autoFillPatches = collectWorkflowStartInputAutoFillPatches({
nodes,
edges: previousEdges,
workflowStartNode: startNodeWithFiles.data
});
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = autoFillPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
const revertPatches = collectWorkflowStartAutoFillRevertPatches({
removedEdges: [{ id: 'e-start-ai', source: 'start', target: 'ai-chat' }],
remainingEdges: previousEdges.filter((edge) => edge.id !== 'e-start-ai'),
getNodeById: (nodeId) => nodes.find((node) => node.data.nodeId === nodeId)?.data
});
expect(revertPatches.map((patch) => `${patch.nodeId}:${patch.key}`).sort()).toEqual(
[
`ai-chat:${NodeInputKeyEnum.fileUrlList}`,
`ai-chat:${NodeInputKeyEnum.userChatInput}`,
`dataset-search:${NodeInputKeyEnum.datasetSearchInput}`,
`classify:${NodeInputKeyEnum.userChatInput}`,
`tool-call:${NodeInputKeyEnum.fileUrlList}`,
`tool-call:${NodeInputKeyEnum.userChatInput}`
].sort()
);
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = revertPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
const result = checkWorkflowNodeIssues({
nodes,
edges: previousEdges.filter((edge) => edge.id !== 'e-start-ai')
});
expect(
result['ai-chat']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
expect(
result['dataset-search']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.datasetSearchInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
expect(
result['tool-call']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
});
it('断开中间连线后应只回滚失去流程开始可达性的下游节点自动填充', () => {
const aiNode = makeFreshConnectedNode('ai-chat', AiChatModule);
const datasetNode = makeFreshConnectedNode('dataset-search', DatasetSearchModule);
const classifyNode = makeFreshConnectedNode('classify', ClassifyQuestionModule);
const toolNode = makeFreshConnectedNode('tool-call', ToolCallNode);
const nodes = [startNodeWithFiles, aiNode, datasetNode, classifyNode, toolNode];
const previousEdges: Edge[] = [
{ id: 'e-start-ai', source: 'start', target: 'ai-chat', type: EDGE_TYPE },
{ id: 'e-ai-dataset', source: 'ai-chat', target: 'dataset-search', type: EDGE_TYPE },
{ id: 'e-ai-classify', source: 'ai-chat', target: 'classify', type: EDGE_TYPE },
{ id: 'e-classify-tool', source: 'classify', target: 'tool-call', type: EDGE_TYPE }
];
const autoFillPatches = collectWorkflowStartInputAutoFillPatches({
nodes,
edges: previousEdges,
workflowStartNode: startNodeWithFiles.data
});
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = autoFillPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
const remainingEdges = previousEdges.filter((edge) => edge.id !== 'e-ai-classify');
const revertPatches = collectWorkflowStartAutoFillRevertPatches({
removedEdges: [{ id: 'e-ai-classify', source: 'ai-chat', target: 'classify' }],
remainingEdges,
getNodeById: (nodeId) => nodes.find((node) => node.data.nodeId === nodeId)?.data
});
expect(revertPatches.map((patch) => `${patch.nodeId}:${patch.key}`).sort()).toEqual(
[
`classify:${NodeInputKeyEnum.userChatInput}`,
`tool-call:${NodeInputKeyEnum.fileUrlList}`,
`tool-call:${NodeInputKeyEnum.userChatInput}`
].sort()
);
expect(
revertPatches.some(
(patch) =>
patch.nodeId === 'dataset-search' && patch.key === NodeInputKeyEnum.datasetSearchInput
)
).toBe(false);
nodes.forEach((node) => {
node.data.inputs = node.data.inputs.map((input) => {
const patch = revertPatches.find(
(item) => item.nodeId === node.data.nodeId && item.key === input.key
);
return patch ? patch.value : input;
});
});
const result = checkWorkflowNodeIssues({
nodes,
edges: remainingEdges
});
expect(
result['classify']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
expect(
result['tool-call']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.userChatInput)
.map((issue) => issue.code) ?? []
).toContain('required_input_empty');
expect(
result['dataset-search']
?.filter((issue) => issue.inputKey === NodeInputKeyEnum.datasetSearchInput)
.map((issue) => issue.code) ?? []
).not.toContain('required_input_empty');
});
});
/**
* 修复后 list.tsx 默认:无 userFiles output 时不注入 fileUrlList;datasetSearchInput 仅含 userChatInput。
*/
describe('new node default refs should not false-positive invalid_reference without userFiles output', () => {
const workflowStartWithoutUserFiles = startNode;
const buildAutoFilledInputs = (template: FlowNodeTemplateType, inputKey: NodeInputKeyEnum) => {
const input = template.inputs.find((item) => item.key === inputKey);
expect(input).toBeDefined();
return applyWorkflowStartInputAutoFill({
inputs: [{ ...input!, value: input?.value ?? input?.defaultValue }],
workflowStartNodeId: workflowStartWithoutUserFiles.data.nodeId,
workflowStartOutputs: workflowStartWithoutUserFiles.data.outputs
})[0]?.value;
};
it('AI chat fileUrlList: default from list.tsx must not report invalid file link reference', () => {
const chatNode = makeNode('chat', FlowNodeTypeEnum.chatNode, {
inputs: [
{
key: NodeInputKeyEnum.fileUrlList,
label: 'app:workflow.user_file_input',
valueType: WorkflowIOValueTypeEnum.arrayString,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.input],
selectedTypeIndex: 0,
value: buildAutoFilledInputs(AiChatModule, NodeInputKeyEnum.fileUrlList)
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [workflowStartWithoutUserFiles, chatNode],
edges: [{ id: 'e1', source: 'start', target: 'chat', type: EDGE_TYPE }]
});
const fileLinkIssues =
result.chat?.filter((issue) => issue.inputKey === NodeInputKeyEnum.fileUrlList) ?? [];
expect(fileLinkIssues).toEqual([]);
});
it('tool call fileUrlList: default from list.tsx must not report invalid file link reference', () => {
const toolCallNode = makeNode('toolCall', FlowNodeTypeEnum.toolCall, {
inputs: [
{
key: NodeInputKeyEnum.fileUrlList,
label: 'app:workflow.user_file_input',
valueType: WorkflowIOValueTypeEnum.arrayString,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.input],
selectedTypeIndex: 0,
value: buildAutoFilledInputs(ToolCallNode, NodeInputKeyEnum.fileUrlList)
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [workflowStartWithoutUserFiles, toolCallNode],
edges: [{ id: 'e1', source: 'start', target: 'toolCall', type: EDGE_TYPE }]
});
const fileLinkIssues =
result.toolCall?.filter((issue) => issue.inputKey === NodeInputKeyEnum.fileUrlList) ?? [];
expect(fileLinkIssues).toEqual([]);
});
it('dataset search datasetSearchInput: default from list.tsx must not report invalid search content reference', () => {
const datasetSearchNode = makeNode('datasetSearch', FlowNodeTypeEnum.datasetSearchNode, {
inputs: [
{
key: NodeInputKeyEnum.datasetSearchInput,
label: 'workflow:search_query',
valueType: WorkflowIOValueTypeEnum.arrayString,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
selectedTypeIndex: 0,
value: buildAutoFilledInputs(DatasetSearchModule, NodeInputKeyEnum.datasetSearchInput)
}
]
});
describe('nodeTemplate2FlowNode', () => { const result = checkWorkflowNodeIssues({
it('should initialize template text once before formatting the instance name', () => { nodes: [workflowStartWithoutUserFiles, datasetSearchNode],
const template: FlowNodeTemplateType = { edges: [{ id: 'e1', source: 'start', target: 'datasetSearch', type: EDGE_TYPE }]
id: 'template1', });
templateType: 'formInput',
name: 'workflow:template_name',
intro: 'workflow:template_intro',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [],
outputs: []
};
const t = vi.fn(
(key: string) =>
({
'workflow:template_name': 'Template Name',
'workflow:template_intro': 'Template Intro'
})[key] ?? key
);
const result = nodeTemplate2FlowNode({ const searchContentIssues =
template, result.datasetSearch?.filter(
position: { x: 100, y: 100 }, (issue) => issue.inputKey === NodeInputKeyEnum.datasetSearchInput
selected: true, ) ?? [];
parentNodeId: 'parent1', expect(searchContentIssues).toEqual([]);
t: t as any, });
formatName: (name) => `${name} 2`
}); });
expect(result).toMatchObject({ it('reports invalid_reference when referenced upstream node or output was deleted', () => {
type: FlowNodeTypeEnum.formInput, const nodeWithDeletedNodeRef = makeNode('deleted-node', FlowNodeTypeEnum.chatNode, {
position: { x: 100, y: 100 }, inputs: [
selected: true, {
data: { key: NodeInputKeyEnum.userChatInput,
name: 'Template Name 2', label: '用户问题',
intro: 'Template Intro', required: true,
flowNodeType: FlowNodeTypeEnum.formInput, valueType: WorkflowIOValueTypeEnum.string,
parentNodeId: 'parent1' renderTypeList: [FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: ['deleted-node-id', NodeOutputKeyEnum.userChatInput]
} }
]
}); });
expect(result.id).toBeDefined(); const nodeWithDeletedOutputRef = makeNode('deleted-output', FlowNodeTypeEnum.chatNode, {
expect(t.mock.calls.map(([key]) => key)).toEqual([ inputs: [
'workflow:template_name', {
'workflow:template_intro' key: NodeInputKeyEnum.userChatInput,
]); label: '用户问题',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: ['start', 'deleted-output-id']
}
]
}); });
});
describe('adaptStoreNodeInputs', () => { const result = checkWorkflowNodeIssues({
const createAgentNode = (inputs: StoreNodeItemType['inputs']): StoreNodeItemType => ({ nodes: [startNode, nodeWithDeletedNodeRef, nodeWithDeletedOutputRef],
nodeId: 'agent-node', edges: [
flowNodeType: FlowNodeTypeEnum.agent, { id: 'e1', source: 'start', target: 'deleted-node', type: EDGE_TYPE },
name: 'Agent', { id: 'e2', source: 'start', target: 'deleted-output', type: EDGE_TYPE }
inputs, ]
outputs: []
}); });
it('should reset legacy Agent resource references to manual selection', () => { expect(result['deleted-node'].map((issue) => issue.code)).toContain('invalid_reference');
const inputs: StoreNodeItemType['inputs'] = [ expect(result['deleted-node'].map((issue) => issue.code)).not.toContain('required_input_empty');
expect(result['deleted-output'].map((issue) => issue.code)).toContain('invalid_reference');
expect(result['deleted-output'].map((issue) => issue.code)).not.toContain(
'required_input_empty'
);
});
it('checks only the requested node while preserving graph context', () => {
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{ {
key: NodeInputKeyEnum.skills, key: NodeInputKeyEnum.answerText,
label: 'Skills', label: 'answer',
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference], required: true,
selectedTypeIndex: 1, valueType: WorkflowIOValueTypeEnum.string,
value: ['source-node', 'skills'] renderTypeList: [FlowNodeInputTypeEnum.input],
}, value: ''
}
]
});
const validNode = makeNode('valid', FlowNodeTypeEnum.answerNode);
const result = checkWorkflowNodeIssues({
nodes: [startNode, requiredNode, validNode],
edges: [
{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'valid', type: EDGE_TYPE }
],
nodeId: 'valid'
});
expect(result.required).toBeUndefined();
expect(result.valid).toBeUndefined();
});
it('returns all error node ids from the structured run/publish check', () => {
const firstNode = makeNode('first', FlowNodeTypeEnum.answerNode, {
inputs: [
{ {
key: NodeInputKeyEnum.selectedTools, key: NodeInputKeyEnum.answerText,
label: 'Tools', label: 'answer',
renderTypeList: [FlowNodeInputTypeEnum.selectTool, FlowNodeInputTypeEnum.reference], required: true,
selectedTypeIndex: 1, valueType: WorkflowIOValueTypeEnum.string,
value: ['source-node', 'tools'] renderTypeList: [FlowNodeInputTypeEnum.input],
}, value: ''
}
]
});
const secondNode = makeNode('second', FlowNodeTypeEnum.formInput, {
inputs: [
{ {
key: NodeInputKeyEnum.datasetSelectList, key: NodeInputKeyEnum.userInputForms,
label: 'Datasets', renderTypeList: [FlowNodeInputTypeEnum.custom],
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference], value: []
selectedTypeIndex: 1,
value: ['source-node', 'datasets']
} }
]
});
const nodes = [startNode, firstNode, secondNode];
const edges = [
{ id: 'e1', source: 'start', target: 'first', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'second', type: EDGE_TYPE }
]; ];
const result = adaptStoreNodeInputs(createAgentNode(inputs)); const result = checkWorkflowBeforeRunOrPublish({ nodes, edges });
expect(result).toEqual( expect(result.errorNodeIds).toEqual(['first', 'second']);
inputs.map((input) => ({ expect(result.firstErrorNodeId).toBe('first');
...input, });
selectedTypeIndex: 0,
it('reports specific node configuration errors for ifElse, classify, code and extract', () => {
const ifElseNode = makeNode('ifElse', FlowNodeTypeEnum.ifElseNode, {
inputs: [
{
key: NodeInputKeyEnum.ifElseList,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: [
{
list: [{ variable: undefined, condition: undefined, value: undefined }]
}
]
}
]
});
const classifyNode = makeNode('classify', FlowNodeTypeEnum.classifyQuestion, {
inputs: [
{
key: NodeInputKeyEnum.agents,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: [] value: []
})) }
]
});
const codeNode = makeNode('code', FlowNodeTypeEnum.code, {
inputs: [
{
key: 'customVar',
label: '',
canEdit: true,
renderTypeList: [FlowNodeInputTypeEnum.reference],
valueType: WorkflowIOValueTypeEnum.any,
value: undefined
}
]
});
const extractNode = makeNode('extract', FlowNodeTypeEnum.contentExtract, {
inputs: [
{
key: NodeInputKeyEnum.extractKeys,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: []
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, ifElseNode, classifyNode, codeNode, extractNode],
edges: [
{ id: 'e1', source: 'start', target: 'ifElse', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'classify', type: EDGE_TYPE },
{ id: 'e3', source: 'start', target: 'code', type: EDGE_TYPE },
{ id: 'e4', source: 'start', target: 'extract', type: EDGE_TYPE }
]
});
expect(result.ifElse.map((issue) => issue.code)).toContain('if_else_incomplete');
expect(result.classify.map((issue) => issue.code)).toContain('classify_question_empty');
expect(result.code.map((issue) => issue.code)).toContain('code_input_incomplete');
expect(result.extract.map((issue) => issue.code)).toContain('context_extract_empty');
});
it('clears single node errors after configuration is fixed', () => {
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: 'fixed answer'
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, requiredNode],
edges: [{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }],
nodeId: 'required'
});
expect(result.required).toBeUndefined();
expect(checkWorkflowHasError(result)).toBe(false);
});
it('supports single node validation', () => {
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{
key: NodeInputKeyEnum.answerText,
label: 'answer',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
]
});
const formNode = makeNode('form', FlowNodeTypeEnum.formInput, {
inputs: [
{
key: NodeInputKeyEnum.userInputForms,
renderTypeList: [FlowNodeInputTypeEnum.custom],
value: []
}
]
});
const result = checkWorkflowNodeIssues({
nodes: [startNode, requiredNode, formNode],
edges: [
{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'form', type: EDGE_TYPE }
],
nodeId: 'required'
});
expect(Object.keys(result)).toEqual(['required']);
});
});
describe('workflow check helpers', () => {
it('detects workflow errors from issue map', () => {
expect(checkWorkflowHasError({ node1: [{ level: 'error' } as any] })).toBe(true);
expect(checkWorkflowHasError({ node1: [{ level: 'warning' } as any] })).toBe(false);
expect(checkWorkflowHasError({})).toBe(false);
});
it('orders error node ids by canvas node order for stable first-error focus', () => {
const issueMap = {
nodeB: [{ level: 'error' } as any],
nodeA: [{ level: 'error' } as any]
};
expect(getWorkflowCheckErrorNodeIds(issueMap)).toEqual(
expect.arrayContaining(['nodeA', 'nodeB'])
); );
expect(getWorkflowCheckErrorNodeIds(issueMap, ['nodeA', 'nodeB', 'nodeC'])).toEqual([
'nodeA',
'nodeB'
]);
}); });
it('should preserve manually selected Agent resources and unrelated inputs', () => { it('returns first error node by canvas order for run/publish checks', () => {
const selectedSkills = [{ skillId: 'skill-1', name: 'Skill 1' }]; const makeNode = (
const promptReference = ['source-node', 'prompt']; nodeId: string,
const inputs: StoreNodeItemType['inputs'] = [ flowNodeType: FlowNodeTypeEnum,
data?: Partial<FlowNodeItemType>
): Node<FlowNodeItemType> =>
({
id: nodeId,
type: flowNodeType,
position: { x: 0, y: 0 },
data: {
nodeId,
flowNodeType,
name: nodeId,
inputs: [],
outputs: [],
...data
}
}) as Node<FlowNodeItemType>;
const startNode = makeNode('start', FlowNodeTypeEnum.workflowStart);
const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, {
inputs: [
{ {
key: NodeInputKeyEnum.skills, key: NodeInputKeyEnum.answerText,
label: 'Skills', label: 'answer',
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference], required: true,
selectedTypeIndex: 0, valueType: WorkflowIOValueTypeEnum.string,
value: selectedSkills renderTypeList: [FlowNodeInputTypeEnum.input],
}, value: ''
}
]
});
const httpNode = makeNode('http', FlowNodeTypeEnum.httpRequest468, {
inputs: [
{ {
key: NodeInputKeyEnum.aiSystemPrompt, key: NodeInputKeyEnum.httpReqUrl,
label: 'Prompt', renderTypeList: [FlowNodeInputTypeEnum.input],
renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], value: ''
selectedTypeIndex: 1,
value: promptReference
} }
]; ]
});
const result = adaptStoreNodeInputs(createAgentNode(inputs)); const result = checkWorkflowBeforeRunOrPublish({
nodes: [startNode, requiredNode, httpNode],
edges: [
{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE },
{ id: 'e2', source: 'start', target: 'http', type: EDGE_TYPE }
]
});
expect(result[0]).toMatchObject({ selectedTypeIndex: 0, value: selectedSkills }); expect(result.hasError).toBe(true);
expect(result[1]).toBe(inputs[1]); expect(result.firstErrorNodeId).toBe('required');
expect(result.errorNodeIds).toEqual(['required', 'http']);
});
it('blocks run/publish style checks when any error exists', () => {
const httpNode = {
id: 'http',
type: FlowNodeTypeEnum.httpRequest468,
position: { x: 0, y: 0 },
data: {
nodeId: 'http',
flowNodeType: FlowNodeTypeEnum.httpRequest468,
inputs: [
{
key: NodeInputKeyEnum.httpReqUrl,
renderTypeList: [FlowNodeInputTypeEnum.input],
value: ''
}
],
outputs: []
}
} as Node<FlowNodeItemType>;
const startNode = {
id: 'start',
type: FlowNodeTypeEnum.workflowStart,
position: { x: 0, y: 0 },
data: {
nodeId: 'start',
flowNodeType: FlowNodeTypeEnum.workflowStart,
inputs: [],
outputs: []
}
} as Node<FlowNodeItemType>;
const issueMap = checkWorkflowNodeIssues({
nodes: [startNode, httpNode],
edges: [{ id: 'e1', source: 'start', target: 'http', type: EDGE_TYPE }]
});
expect(checkWorkflowHasError(issueMap)).toBe(true);
expect(getWorkflowCheckErrorNodeIds(issueMap, ['start', 'http'])).toEqual(['http']);
}); });
}); });
...@@ -580,9 +2014,20 @@ describe('getNodeAllSource', () => { ...@@ -580,9 +2014,20 @@ describe('getNodeAllSource', () => {
}); });
}); });
describe('checkWorkflowNodeAndConnection', () => { describe('checkWorkflowBeforeRunOrPublish', () => {
const getErrorNodeIds = ({
nodes,
edges
}: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge[];
}) => {
const { errorNodeIds } = checkWorkflowBeforeRunOrPublish({ nodes, edges });
return errorNodeIds.length > 0 ? errorNodeIds : undefined;
};
it('should validate nodes and connections', () => { it('should validate nodes and connections', () => {
const nodes: Node[] = [ const nodes: Node<FlowNodeItemType>[] = [
{ {
id: 'node1', id: 'node1',
type: FlowNodeTypeEnum.formInput, type: FlowNodeTypeEnum.formInput,
...@@ -612,12 +2057,12 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -612,12 +2057,12 @@ describe('checkWorkflowNodeAndConnection', () => {
} }
]; ];
const result = checkWorkflowNodeAndConnection({ nodes, edges }); const result = getErrorNodeIds({ nodes, edges });
expect(result).toEqual(['node1']); expect(result).toEqual(['node1']);
}); });
it('should handle empty nodes and edges', () => { it('should handle empty nodes and edges', () => {
const result = checkWorkflowNodeAndConnection({ nodes: [], edges: [] }); const result = getErrorNodeIds({ nodes: [], edges: [] });
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
...@@ -701,7 +2146,7 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -701,7 +2146,7 @@ describe('checkWorkflowNodeAndConnection', () => {
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']), makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart) makeChild('start1', FlowNodeTypeEnum.loopRunStart)
]; ];
const result = checkWorkflowNodeAndConnection({ const result = getErrorNodeIds({
nodes, nodes,
edges: [wsToLoop, stubEdge('start1')] edges: [wsToLoop, stubEdge('start1')]
}); });
...@@ -721,7 +2166,7 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -721,7 +2166,7 @@ describe('checkWorkflowNodeAndConnection', () => {
target: 'break1', target: 'break1',
type: EDGE_TYPE type: EDGE_TYPE
}; };
const result = checkWorkflowNodeAndConnection({ const result = getErrorNodeIds({
nodes, nodes,
edges: [wsToLoop, startToBreak] edges: [wsToLoop, startToBreak]
}); });
...@@ -735,20 +2180,20 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -735,20 +2180,20 @@ describe('checkWorkflowNodeAndConnection', () => {
makeChild('start1', FlowNodeTypeEnum.loopRunStart), makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak) // 属于别的 loopRun makeChild('break1', FlowNodeTypeEnum.loopRunBreak) // 属于别的 loopRun
]; ];
const result = checkWorkflowNodeAndConnection({ const result = getErrorNodeIds({
nodes, nodes,
edges: [wsToLoop, stubEdge('start1'), stubEdge('break1')] edges: [wsToLoop, stubEdge('start1'), stubEdge('break1')]
}); });
expect(result).toEqual(['loop1']); expect(result).toEqual(['loop1', 'break1']);
}); });
it('数组模式不强制要求 loopRunBreak', () => { it('数组模式不强制要求 loopRunBreak', () => {
const loop = makeLoopRunNode(LoopRunModeEnum.array, ['start1']); const loop = makeLoopRunNode(LoopRunModeEnum.array, ['start1']);
// 数组模式下 loopRunInputArray 必填,填个非空 value 走通用校验 // 数组模式下 loopRunInputArray 必填,填个非空 value 走通用校验
const arrInput = loop.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunInputArray)!; const arrInput = loop.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunInputArray)!;
arrInput.value = ['ws', 'userChatInput']; arrInput.value = [[VARIABLE_NODE_ID, 'bar']];
const nodes = [workflowStart, loop, makeChild('start1', FlowNodeTypeEnum.loopRunStart)]; const nodes = [workflowStart, loop, makeChild('start1', FlowNodeTypeEnum.loopRunStart)];
const result = checkWorkflowNodeAndConnection({ const result = getErrorNodeIds({
nodes, nodes,
edges: [wsToLoop, stubEdge('start1')] edges: [wsToLoop, stubEdge('start1')]
}); });
...@@ -770,7 +2215,7 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -770,7 +2215,7 @@ describe('checkWorkflowNodeAndConnection', () => {
target: 'break1', target: 'break1',
type: EDGE_TYPE type: EDGE_TYPE
}; };
const result = checkWorkflowNodeAndConnection({ const result = getErrorNodeIds({
nodes, nodes,
edges: [wsToLoop, startToBreak] edges: [wsToLoop, startToBreak]
}); });
...@@ -821,7 +2266,7 @@ describe('checkWorkflowNodeAndConnection', () => { ...@@ -821,7 +2266,7 @@ describe('checkWorkflowNodeAndConnection', () => {
const connectedEdges: Edge[] = [{ id: 'e1', source: 's1', target: 'u1', type: EDGE_TYPE }]; const connectedEdges: Edge[] = [{ id: 'e1', source: 's1', target: 'u1', type: EDGE_TYPE }];
const run = (updateList: any[]) => const run = (updateList: any[]) =>
checkWorkflowNodeAndConnection({ getErrorNodeIds({
nodes: [startNode, makeVarUpdateNode(updateList)], nodes: [startNode, makeVarUpdateNode(updateList)],
edges: connectedEdges edges: connectedEdges
}); });
......
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