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 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({ const newNode = nodeTemplate2FlowNode({
template: { template: {
...templateNode, ...templateNode,
inputs: templateNode.inputs name: computedNewNodeName({
.filter((input) => input.deprecated !== true) templateName: t(templateNode.name as any),
.map((input) => ({ flowNodeType: templateNode.flowNodeType,
...input, pluginId: templateNode.pluginId
value: defaultValueMap[input.key] ?? input.value ?? input.defaultValue, }),
valueDesc: input.valueDesc ? t(input.valueDesc as any) : undefined, intro: t(templateNode.intro as any),
label: t(input.label as any), inputs: inputsWithAutoFill,
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
})),
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));
toast({ onSyncWorkflowCheckIssues(issueMap);
status: 'warning',
title: t('common:core.workflow.Check Failed') if (firstErrorNodeId) {
}); onUpdateNodeError(firstErrorNodeId, true);
return Promise.reject(); 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( 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 if (!workflowStartNode) return;
const node = getNodeById(connect.target);
if (!node) return;
// 1. Add file input const patches = collectWorkflowStartInputAutoFillPatches({
if ( nodes,
node.flowNodeType === FlowNodeTypeEnum.chatNode || edges: nextEdges,
node.flowNodeType === FlowNodeTypeEnum.toolCall || workflowStartNode
node.flowNodeType === FlowNodeTypeEnum.appModule });
) {
const input = node.inputs.find((i) => i.key === NodeInputKeyEnum.fileUrlList); if (patches.length > 0) {
if (input && (!input?.value || input.value.length === 0)) { onChangeNode(patches.map((patch) => ({ ...patch, type: 'updateInput' as const })));
if (!workflowStartNode) return;
onChangeNode({
nodeId: node.nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.fileUrlList,
value: {
...input,
value: [[workflowStartNode.nodeId, NodeOutputKeyEnum.userFiles]]
}
});
}
} }
}, },
[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,23 +46,20 @@ const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { ...@@ -45,23 +46,20 @@ 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,
value: {
...props,
key: agentKey, key: agentKey,
value: { value: agents.filter((input) => input.key !== item.key)
...props,
key: agentKey,
value: agents.filter((input) => input.key !== item.key)
}
},
{
nodeId,
type: 'delOutput',
key: item.key
} }
]); });
onDelEdge({
nodeId,
sourceHandle: getHandleId(nodeId, 'source', 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,
nodeId: workflowStartNode.nodeId, edges,
type: 'addOutput', workflowStartNode: {
value: userFilesInput ...workflowStartNode,
}); outputs: repeatKey
} else { ? workflowStartNode.outputs
repeatKey && : [...workflowStartNode.outputs, userFilesInput]
onChangeNode({ }
});
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, 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;
......
// 工作流工具函数层 // 工作流工具函数层
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));
// View move to the node that failed if (!hideTip) {
fitView({ onSyncWorkflowCheckIssues(issueMap);
nodes: nodes.filter((node) => checkResults.includes(node.data.nodeId)),
padding: 0.3 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',
...@@ -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 (
......
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