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 = ({
label: `${i18nT('workflow:quote_num')}-${index}`,
debugLabel: i18nT('workflow:knowledge_base_reference'),
canEdit: true,
valueType: WorkflowIOValueTypeEnum.datasetQuote
valueType: WorkflowIOValueTypeEnum.datasetQuote,
required: true
});
export const DatasetConcatModule: FlowNodeTemplateType = {
......
......@@ -66,7 +66,7 @@ export const HttpNode468: FlowNodeTemplateType = {
label: '',
description: i18nT('common:core.module.input.description.Http Request Url'),
placeholder: 'https://api.ai.com/getInventory',
required: false
required: true
},
{
key: NodeInputKeyEnum.headerSecret,
......
......@@ -264,11 +264,27 @@ export const NodeTemplateListTypeSchema = z.array(
);
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
export const FlowNodeItemSchema = FlowNodeTemplateTypeSchema.extend({
nodeId: z.string(),
parentNodeId: z.string().optional(),
isError: BoolSchema.optional(),
workflowCheckIssues: z.array(WorkflowCheckIssueSchema).optional(),
searchedText: z.string().optional(),
debugResult: z
.object({
......
......@@ -178,6 +178,8 @@ export const iconPaths = {
'core/app/type/workflow': () => import('./icons/core/app/type/workflow.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/workflow/checkPendingImprove': () =>
import('./icons/core/app/workflow/checkPendingImprove.svg'),
'core/chat/QGFill': () => import('./icons/core/chat/QGFill.svg'),
'core/chat/backText': () => import('./icons/core/chat/backText.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 @@
"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.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.Confirm stop debug": "Confirm to Stop Debugging? Debug Information Will Not Be Retained.",
"core.workflow.Copy node": "Node Copied",
......
......@@ -650,6 +650,26 @@
"core.plugin.Get Plugin Module Detail Failed": "加载插件异常",
"core.tip.leave page": "内容已修改,确认离开页面吗?",
"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.Confirm stop debug": "确认终止调试?调试信息将会不保留。",
"core.workflow.Copy node": "已复制节点",
......
......@@ -645,6 +645,26 @@
"core.plugin.Get Plugin Module Detail Failed": "取得外掛程式資訊失敗",
"core.tip.leave page": "內容已修改,確認離開頁面嗎?",
"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.Confirm stop debug": "確認停止除錯?除錯資訊將不會保留。",
"core.workflow.Copy node": "已複製節點",
......
......@@ -28,11 +28,8 @@ import type { AppVersionSchemaType } from '@fastgpt/global/core/app/version/type
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { isProduction } from '@fastgpt/global/common/system/constants';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
checkWorkflowNodeAndConnection,
storeEdge2RenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
import { storeEdge2RenderEdge, storeNode2FlowNode } from '@/web/core/workflow/utils';
import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck';
import type { AppForm2WorkflowFnType, Form2WorkflowFnType } from './type';
import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { useUserStore } from '@/web/support/user/useUserStore';
......@@ -290,15 +287,15 @@ const Header = ({
const nodes = storeNodes.map((item) => storeNode2FlowNode({ item, t }));
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({
title: t('app:app.error.publish_unExist_app'),
status: 'warning'
});
}
return !checkResults;
return !checkResults.hasError;
}}
/>
</>
......
......@@ -7,9 +7,9 @@ import { Box } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useMemoizedFn } from 'ahooks';
import React from 'react';
import { XYPosition } from 'reactflow';
import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../context/workflowInitContext';
import { WorkflowActionsContext } from '../context/workflowActionsContext';
type ModuleTemplateListProps = {
isOpen: boolean;
......@@ -20,6 +20,10 @@ export const sliderWidth = 460;
const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
const onRefreshSingleNodeWorkflowCheckIssues = useContextSelector(
WorkflowActionsContext,
(v) => v.onRefreshSingleNodeWorkflowCheckIssues
);
const {
templateType,
......@@ -47,6 +51,11 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
.concat(newNodes);
return newState;
});
// 新增节点后立即同步下方待完善提示,不依赖 10s 定时扫描或用户首次编辑。
setTimeout(() => {
onRefreshSingleNodeWorkflowCheckIssues(newNodes[0]?.data.nodeId ?? '');
}, 0);
});
return (
......
import MyBox from '@fastgpt/web/components/common/MyBox';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { collectWorkflowStartInputAutoFillPatches } from '@/web/core/workflow/workflowStartAutoFill';
import { Popover, PopoverBody, PopoverContent } from '@chakra-ui/react';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import {
EDGE_TYPE,
FlowNodeTypeEnum,
isNestedChildSystemNodeType
} from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type Node } from 'reactflow';
import { WorkflowBufferDataContext } from '../context/workflowInitContext';
import MyBox from '@fastgpt/web/components/common/MyBox';
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 NodeTemplateList from './components/NodeTemplates/list';
import { Popover, PopoverContent, PopoverBody } from '@chakra-ui/react';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { popoverHeight, popoverWidth } from './hooks/useWorkflow';
import { WorkflowModalContext } from '../context/workflowModalContext';
const NodeTemplatesPopover = () => {
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 {
templateType,
......@@ -87,7 +98,25 @@ const NodeTemplatesPopover = () => {
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);
setTimeout(() => {
newNodes.forEach((node) => {
onRefreshSingleNodeWorkflowCheckIssues(node.data.nodeId);
});
}, 0);
});
if (!handleParams) return null;
......
......@@ -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 { useReactFlow } from 'reactflow';
import type { Node } from 'reactflow';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
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 { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore';
......@@ -235,7 +236,7 @@ const NodeTemplateList = ({
const { t, i18n } = useTranslation();
const { toast } = useToast();
const { computedNewNodeName } = useWorkflowUtils();
const { getNodeList, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams);
const { getIntersectingNodes } = useReactFlow();
......@@ -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);
// Popover insertion inherits the source node's parent; a dragged
......@@ -359,29 +338,43 @@ const NodeTemplateList = ({
}
}
const preparedInputs = templateNode.inputs
.filter((input) => input.deprecated !== true)
.map((input) => ({
...input,
value: input.value ?? input.defaultValue,
valueDesc: input.valueDesc ? t(input.valueDesc as any) : undefined,
label: t(input.label as any),
description: input.description ? t(input.description as any) : undefined,
placeholder: input.placeholder ? t(input.placeholder as any) : undefined,
debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined,
toolDescription: input.toolDescription ? t(input.toolDescription as any) : undefined,
list: Array.isArray(input.list)
? input.list.map((opt: any) => ({
...opt,
label: opt?.label ? t(opt.label as any) : opt?.label
}))
: input.list
}));
const inputsWithAutoFill =
currentNode?.flowNodeType === FlowNodeTypeEnum.workflowStart
? applyWorkflowStartInputAutoFill({
inputs: preparedInputs,
workflowStartNodeId: currentNode.nodeId,
workflowStartOutputs: currentNode.outputs
})
: preparedInputs;
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
inputs: templateNode.inputs
.filter((input) => input.deprecated !== true)
.map((input) => ({
...input,
value: defaultValueMap[input.key] ?? input.value ?? input.defaultValue,
valueDesc: input.valueDesc ? t(input.valueDesc as any) : undefined,
label: t(input.label as any),
description: input.description ? t(input.description as any) : undefined,
placeholder: input.placeholder ? t(input.placeholder as any) : undefined,
debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined,
toolDescription: input.toolDescription
? t(input.toolDescription as any)
: undefined,
list: Array.isArray(input.list)
? input.list.map((opt: any) => ({
...opt,
label: opt?.label ? t(opt.label as any) : opt?.label
}))
: input.list
})),
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
.filter((output) => output.deprecated !== true)
.map((output) => ({
......@@ -441,16 +434,7 @@ const NodeTemplateList = ({
console.error('Failed to create node template:', error);
}
},
[
computedNewNodeName,
getNodeById,
handleParams,
getNodeList,
getIntersectingNodes,
onAddNode,
t,
toast
]
[computedNewNodeName, getNodeById, handleParams, getIntersectingNodes, onAddNode, t, toast]
);
const formatTemplatesArrayData = useMemo(() => {
......
......@@ -5,7 +5,9 @@ import {
type StoreEdgeItemType
} from '@fastgpt/global/core/workflow/type/edge';
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 { uiWorkflow2StoreWorkflow } from '../../utils';
import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
......@@ -58,7 +60,11 @@ export const useDebug = () => {
WorkflowBufferDataContext,
(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 appDetail = useContextSelector(AppContext, (v) => v.appDetail);
......@@ -94,22 +100,48 @@ export const useDebug = () => {
const flowData2StoreDataAndCheck = useCallback(async () => {
const nodes = getNodes();
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });
if (!checkResults) {
const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({
nodes,
edges,
t: workflowT
});
if (!hasError) {
onRemoveError();
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges });
return JSON.stringify(storeNodes);
} else {
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
}
toast({
status: 'warning',
title: t('common:core.workflow.Check Failed')
});
return Promise.reject();
onSyncWorkflowCheckIssues(issueMap);
if (firstErrorNodeId) {
onUpdateNodeError(firstErrorNodeId, true);
const firstErrorNode = nodes.find((node) => node.data.nodeId === firstErrorNodeId);
if (firstErrorNode) {
fitView({
nodes: [firstErrorNode],
padding: 0.3
});
}
}
}, [edges, getNodes, onRemoveError, onUpdateNodeError, t, toast]);
toast({
status: 'warning',
title: t('common:core.workflow.Check Failed')
});
return Promise.reject();
}, [
edges,
fitView,
getNodes,
onRemoveError,
onSyncWorkflowCheckIssues,
onUpdateNodeError,
t,
toast,
workflowT
]);
const openDebugNode = useCallback(
async ({ entryNodeId }: { entryNodeId: string }) => {
......
......@@ -26,7 +26,8 @@ import { useTranslation } from 'next-i18next';
import { useKeyboard } from './useKeyboard';
import { useContextSelector } from 'use-context-selector';
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 { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import {
......@@ -469,7 +470,10 @@ export const useWorkflow = () => {
} = useContextSelector(WorkflowBufferDataContext, (state) => state);
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 { setHoverEdgeId, setMenu } = useContextSelector(WorkflowUIContext, (v) => v);
......@@ -662,8 +666,16 @@ export const useWorkflow = () => {
change.selected = true;
}
// 错误节点失焦(取消选中)时清除标红,与原版点击节点取消标红行为一致。
if (!change.selected) {
const node = getRawNodeById(change.id);
if (node?.data.isError) {
onUpdateNodeError(node.data.nodeId, false);
}
return;
}
// 父子互斥(后操作优先): 选父则取消其已选 children;选子则取消已选父。
if (!change.selected) return;
const node = getRawNodeById(change.id);
if (!node) return;
......@@ -912,6 +924,14 @@ export const useWorkflow = () => {
}, [setConnectingEdge]);
const onConnect = useCallback(
({ connect }: { connect: Connection }) => {
const nextEdges = addEdge(
{
...connect,
type: EDGE_TYPE
},
edges
);
setEdges((state) =>
addEdge(
{
......@@ -922,32 +942,19 @@ export const useWorkflow = () => {
)
);
// Add default input
const node = getNodeById(connect.target);
if (!node) return;
if (!workflowStartNode) 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;
onChangeNode({
nodeId: node.nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.fileUrlList,
value: {
...input,
value: [[workflowStartNode.nodeId, NodeOutputKeyEnum.userFiles]]
}
});
}
const patches = collectWorkflowStartInputAutoFillPatches({
nodes,
edges: nextEdges,
workflowStartNode
});
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(
(connect: Connection) => {
......
......@@ -21,6 +21,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs } = data;
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge);
const CustomComponent = useMemo(
() => ({
......@@ -45,23 +46,20 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
color={'myGray.600'}
_hover={{ color: 'red.600' }}
onClick={() => {
onChangeNode([
{
nodeId,
type: 'updateInput',
onChangeNode({
nodeId,
type: 'updateInput',
key: agentKey,
value: {
...props,
key: agentKey,
value: {
...props,
key: agentKey,
value: agents.filter((input) => input.key !== item.key)
}
},
{
nodeId,
type: 'delOutput',
key: item.key
value: agents.filter((input) => input.key !== item.key)
}
]);
});
onDelEdge({
nodeId,
sourceHandle: getHandleId(nodeId, 'source', item.key)
});
}}
/>
</MyTooltip>
......@@ -129,7 +127,7 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
);
}
}),
[nodeId, onChangeNode, t]
[nodeId, onChangeNode, onDelEdge, t]
);
const Render = useMemo(() => {
......
......@@ -87,13 +87,13 @@ const NodeDatasetConcat = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
}}
/>
),
[NodeInputKeyEnum.datasetQuoteList]: (item: FlowNodeInputItemType) => {
[NodeInputKeyEnum.datasetQuoteList]: () => {
return (
<>
<HStack className="nodrag" cursor={'default'} position={'relative'}>
<HStack spacing={1} position={'relative'} fontWeight={'medium'} color={'myGray.600'}>
<Box>{t('common:core.workflow.Dataset quote')}</Box>
</HStack>
<FormLabel required color={'myGray.600'} fontWeight={'medium'}>
{t('common:core.workflow.Dataset quote')}
</FormLabel>
<Box flex={'1 0 0'} />
<Button
variant={'whiteBase'}
......
......@@ -56,6 +56,7 @@ import CatchError from '../render/RenderOutput/CatchError';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
const CurlImportModal = dynamic(() => import('./CurlImportModal'));
const HeaderAuthConfig = dynamic(() => import('@/components/common/secret/HeaderAuthConfig'));
......@@ -192,9 +193,9 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({
return (
<Box>
<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')}
</Box>
</FormLabel>
<Button variant={'link'} onClick={onOpenCurl}>
{t('common:core.module.http.curl import')}
</Button>
......@@ -501,6 +502,7 @@ const RenderForm = ({
const [shouldUpdateNode, setShouldUpdateNode] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local list when external input.value changes
setList(input.value || []);
}, [input.value]);
......@@ -515,6 +517,7 @@ const RenderForm = ({
value: list
}
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset flag after persisting list to node
setShouldUpdateNode(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
......
......@@ -12,7 +12,7 @@ import { TTSTypeEnum } from '@/web/core/app/constants';
import NodeCard from './render/NodeCard';
import ScheduledTriggerConfig from '@/components/core/app/ScheduledTriggerConfig';
import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../../context/workflowInitContext';
import { WorkflowBufferDataContext, WorkflowInitContext } from '../../context/workflowInitContext';
import {
type AppChatConfigType,
type AppDetailType,
......@@ -26,6 +26,10 @@ import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/wo
import Container from '../components/Container';
import AutoExecConfig from '@/components/core/app/AutoExecConfig';
import { WorkflowActionsContext } from '../../context/workflowActionsContext';
import {
collectWorkflowStartInputAutoFillPatches,
collectWorkflowStartOutputAutoFillRevertPatches
} from '@/web/core/workflow/workflowStartAutoFill';
type ComponentProps = {
chatConfig: AppChatConfigType;
......@@ -250,6 +254,8 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co
WorkflowBufferDataContext,
(v) => v.workflowStartNode
);
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
if (!workflowStartNode) return null;
......@@ -274,19 +280,45 @@ function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: Co
e.canSelectCustomFileExtension;
const repeatKey = workflowStartNode.outputs.find((item) => item.key === userFilesInput.key);
if (canUploadFiles) {
!repeatKey &&
onChangeNode({
nodeId: workflowStartNode.nodeId,
type: 'addOutput',
value: userFilesInput
});
} else {
repeatKey &&
onChangeNode({
const patches = collectWorkflowStartInputAutoFillPatches({
nodes,
edges,
workflowStartNode: {
...workflowStartNode,
outputs: repeatKey
? workflowStartNode.outputs
: [...workflowStartNode.outputs, userFilesInput]
}
});
onChangeNode([
...(!repeatKey
? [
{
nodeId: workflowStartNode.nodeId,
type: 'addOutput' as const,
value: userFilesInput
}
]
: []),
...patches.map((patch) => ({ ...patch, type: 'updateInput' as const }))
]);
} else if (repeatKey) {
const patches = collectWorkflowStartOutputAutoFillRevertPatches({
nodes,
edges,
workflowStartNode,
outputKey: userFilesInput.key
});
onChangeNode([
...patches.map((patch) => ({ ...patch, type: 'updateInput' as const })),
{
nodeId: workflowStartNode.nodeId,
type: 'delOutput',
key: userFilesInput.key
});
}
]);
}
}}
/>
......
......@@ -140,9 +140,11 @@ const OptionItem = ({
}) => {
const { t } = useTranslation();
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge);
const { key: optionKey, value, ...props } = itemValue;
const options = value as UserSelectOptionItemType[];
/* eslint-disable react-hooks/refs -- @hello-pangea/dnd passes refs via render props */
return (
<Box
mb={4}
......@@ -173,6 +175,10 @@ const OptionItem = ({
value: options.filter((input) => input.key !== item.key)
}
});
onDelEdge({
nodeId,
sourceHandle: getHandleId(nodeId, 'source', item.key)
});
}}
/>
</MyTooltip>
......
......@@ -26,6 +26,7 @@ import { getClientToolPreviewNode } from '@/web/core/app/api/tool';
import { getAppVersionList } from '@/web/core/app/api/version';
import { getTeamToolVersions } from '@/web/core/plugin/team/api';
import { storeNode2FlowNode } from '@/web/core/workflow/utils';
import { getWorkflowCheckIssueUIStatus } from '@/web/core/workflow/workflowCheck';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useContextSelector } from 'use-context-selector';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
......@@ -63,6 +64,7 @@ import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import type { SystemToolVersionType } from '@fastgpt/global/core/app/tool/systemTool/type/base';
import DebugToolTag from '@fastgpt/web/components/core/plugin/tool/DebugToolTag';
import type { WorkflowCheckIssue } from '@fastgpt/global/core/workflow/type/node';
type Props = FlowNodeItemType & {
children?: React.ReactNode | React.ReactNode[] | string;
......@@ -121,6 +123,7 @@ const NodeCard = (props: Props) => {
menuForbid,
isTool = false,
isError = false,
workflowCheckIssues,
debugResult,
isFolded,
customStyle,
......@@ -215,6 +218,11 @@ const NodeCard = (props: Props) => {
);
}, [isFolded, avatar, avatarLinear, name, handleDoubleClick]);
const errorIssues = useMemo(
() => workflowCheckIssues?.filter((issue) => issue.level === 'error') ?? [],
[workflowCheckIssues]
);
const { outlineColor, outlineWidth } = useMemo(() => {
// error mode
if (isError) return { outlineColor: '#F97066', outlineWidth: '4px solid' };
......@@ -352,6 +360,7 @@ const NodeCard = (props: Props) => {
return (
<Flex
position={'relative'}
outline={selected && (presentationMode || isFolded) ? '16px solid' : undefined}
outlineColor={'rgba(17, 24, 36, 0.05)'}
borderRadius={isFolded ? 26 : 'lg'}
......@@ -497,12 +506,96 @@ const NodeCard = (props: Props) => {
/>
)}
</Flex>
{!isFolded && errorIssues.length > 0 && (
<Box position={'absolute'} top={'100%'} left={0} w={'100%'}>
<NodeWorkflowCheckIssues issues={errorIssues} />
</Box>
)}
</Flex>
);
};
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<{
nodeId: string;
......
// 工作流工具函数层
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 { useReactFlow } from 'reactflow';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
checkWorkflowNodeAndConnection,
adaptCatchError,
storeNode2FlowNode,
storeEdge2RenderEdge
} from '@/web/core/workflow/utils';
import {
checkWorkflowBeforeRunOrPublish,
checkWorkflowNodeIssues
} from '@/web/core/workflow/workflowCheck';
import { uiWorkflow2StoreWorkflow } from '../utils';
import {
FlowNodeOutputTypeEnum,
......@@ -67,47 +70,25 @@ type WorkflowUtilsContextValue = {
};
};
export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({
initData: function (
e: {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
},
isInit?: boolean
): Promise<void> {
initData: (...args: Parameters<WorkflowUtilsContextValue['initData']>) => {
void args;
throw new Error('Function not implemented.');
},
flowData2StoreData: function ():
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined {
flowData2StoreData: () => {
throw new Error('Function not implemented.');
},
flowData2StoreDataAndCheck: function (hideTip?: boolean):
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined {
flowData2StoreDataAndCheck: (
...args: Parameters<WorkflowUtilsContextValue['flowData2StoreDataAndCheck']>
) => {
void args;
throw new Error('Function not implemented.');
},
splitOutput: function (outputs: FlowNodeOutputItemType[]): {
successOutputs: FlowNodeOutputItemType[];
hiddenOutputs: FlowNodeOutputItemType[];
errorOutputs: FlowNodeOutputItemType[];
} {
splitOutput: (...args: Parameters<WorkflowUtilsContextValue['splitOutput']>) => {
void args;
throw new Error('Function not implemented.');
},
splitToolInputs: function (
inputs: FlowNodeInputItemType[],
nodeId: string
): {
isTool: boolean;
toolInputs: FlowNodeInputItemType[];
commonInputs: FlowNodeInputItemType[];
} {
splitToolInputs: (...args: Parameters<WorkflowUtilsContextValue['splitToolInputs']>) => {
void args;
throw new Error('Function not implemented.');
}
});
......@@ -115,7 +96,7 @@ export const WorkflowUtilsContext = createContext<WorkflowUtilsContextValue>({
export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation();
const { toast } = useToast();
const { fitView, getViewport, setViewport } = useReactFlow();
const { fitView } = useReactFlow();
const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore();
const showSandbox = feConfigs?.show_agent_sandbox;
......@@ -127,7 +108,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
(v) => v
);
const { past, setPast } = useContextSelector(WorkflowSnapshotContext, (v) => v);
const { onRemoveError, onUpdateNodeError, onChangeNode } = useContextSelector(
const { onRemoveError, onUpdateNodeError, onSyncWorkflowCheckIssues } = useContextSelector(
WorkflowActionsContext,
(v) => v
);
......@@ -225,21 +206,32 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
return;
}
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });
const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({
nodes,
edges,
t
});
if (!checkResults) {
if (!hasError) {
onRemoveError();
const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges });
return storeWorkflow;
} else if (!hideTip) {
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
}
// View move to the node that failed
fitView({
nodes: nodes.filter((node) => checkResults.includes(node.data.nodeId)),
padding: 0.3
});
if (!hideTip) {
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({
status: 'warning',
......@@ -251,15 +243,33 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
getNodes,
edges,
onRemoveError,
onSyncWorkflowCheckIssues,
fitView,
toast,
t,
onUpdateNodeError,
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 - 初始化工作流数据
const initData = useCallback(
async (
......
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