diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index 1873468f1f53..e3536caba9b8 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -256,11 +256,27 @@ export const NodeTemplateListTypeSchema = z.array( ); export type NodeTemplateListType = z.infer; +export const WorkflowCheckIssueLevelSchema = z.enum(['error', 'warning']); +export type WorkflowCheckIssueLevel = z.infer; + +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; +export type WorkflowCheckNodeIssueMap = Record; + // 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({ diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 85956e8196c8..e08a9070c710 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -631,6 +631,25 @@ "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_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", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index d27b5b1d35c3..cf545a782bee 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -631,6 +631,25 @@ "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_no_permission": "当前账号无权限访问该资源", + "core.workflow.check.tool_offline": "该工具已停用,请删除", "core.workflow.Check Failed": "工作流校验失败,请检查是否缺失、缺值,连线是否正常", "core.workflow.Confirm stop debug": "确认终止调试?调试信息将会不保留。", "core.workflow.Copy node": "已复制节点", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 589244fb6b6b..166716c1e23a 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -626,6 +626,25 @@ "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_no_permission": "當前帳號無權限存取該資源", + "core.workflow.check.tool_offline": "該工具已停用,請刪除", "core.workflow.Check Failed": "工作流校驗失敗,請檢查是否遺失、缺值,連線是否正常", "core.workflow.Confirm stop debug": "確認停止除錯?除錯資訊將不會保留。", "core.workflow.Copy node": "已複製節點", diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx index 0353d9052af0..5a9eb4d36cf6 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx @@ -5,7 +5,8 @@ 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 { checkWorkflowBeforeRunOrPublish, getNodeAllSource } from '@/web/core/workflow/utils'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { uiWorkflow2StoreWorkflow } from '../../utils'; import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; @@ -58,7 +59,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 +99,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 }) => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx index df50035da29d..71c203b18373 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -469,7 +469,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 +665,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; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx index 8912af522c43..d3b03f36876d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx @@ -25,7 +25,7 @@ import { useDebug } from '../../hooks/useDebug'; 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 { storeNode2FlowNode, getWorkflowCheckIssueUIStatus } from '@/web/core/workflow/utils'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { useContextSelector } from 'use-context-selector'; import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; @@ -58,6 +58,7 @@ import { getAppPermission } from '@/web/core/app/api'; 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 type { WorkflowCheckIssue } from '@fastgpt/global/core/workflow/type/node'; type Props = FlowNodeItemType & { children?: React.ReactNode | React.ReactNode[] | string; @@ -99,6 +100,7 @@ const NodeCard = (props: Props) => { menuForbid, isTool = false, isError = false, + workflowCheckIssues, debugResult, isFolded, customStyle, @@ -193,6 +195,11 @@ const NodeCard = (props: Props) => { ); }, [isFolded, avatar, avatarLinear, name, handleDoubleClick, t]); + const errorIssues = useMemo( + () => workflowCheckIssues?.filter((issue) => issue.level === 'error') ?? [], + [workflowCheckIssues] + ); + const { outlineColor, outlineWidth } = useMemo(() => { // error mode if (isError) return { outlineColor: '#F97066', outlineWidth: '4px solid' }; @@ -302,10 +309,11 @@ const NodeCard = (props: Props) => { return ( { outline={outlineWidth} outlineColor={outlineColor} borderRadius={isFolded ? 26 : 'lg'} + boxShadow={'0 24px 40px 0 rgba(0, 0, 0, 0.05)'} _hover={{ boxShadow: '0 24px 40px 0 rgba(0, 0, 0, 0.08)', '& .controller-menu': { @@ -444,12 +453,126 @@ const NodeCard = (props: Props) => { /> )} + {!isFolded && errorIssues.length > 0 && } ); }; export default React.memo(NodeCard); +/** 待处理/待完善状态图标:待完善用设计稿虚线圆环,待处理用圆形 info。 */ +const WorkflowCheckIssueStatusIcon = React.memo(function WorkflowCheckIssueStatusIcon({ + status +}: { + status: ReturnType; +}) { + if (status === 'pending_handle') { + return ( + + + + ); + } + + return ( + + + + ); +}); + +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 ( + + {issues.map((issue, index) => { + const status = getWorkflowCheckIssueUIStatus(issue.code); + const statusPrefixKey = + status === 'pending_handle' + ? 'common:core.workflow.check.status.pending_handle' + : 'common:core.workflow.check.status.pending_improve'; + + return ( + + + + {t(statusPrefixKey)}: + + + {issue.message.trim()} + + + ); + })} + + ); +}); + // 节点标题区域组件 const NodeTitleSection = React.memo<{ nodeId: string; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx index bcd3eb68c0a7..fd48d3a35adc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -1,5 +1,5 @@ // 工作流 Node/Edge 操作层 -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContextSelector } from 'use-context-selector'; import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; @@ -10,8 +10,12 @@ import type { FlowNodeInputItemType, FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; -import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; +import type { + FlowNodeTemplateType, + WorkflowCheckNodeIssueMap +} from '@fastgpt/global/core/workflow/type/node'; import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { checkWorkflowNodeIssues } from '@/web/core/workflow/utils'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; type FlowNodeChangeProps = { nodeId: string } & ( @@ -65,6 +69,12 @@ type WorkflowActionsContextValue = { /** 更新节点错误状态 */ onUpdateNodeError: (nodeId: string, isError: boolean) => void; + /** 批量同步节点校验问题详情;不改动 isError */ + onSyncWorkflowCheckIssues: (nodeIssueMap: WorkflowCheckNodeIssueMap) => void; + + /** 单节点刷新校验问题详情,用于节点配置编辑后的局部复查 */ + onRefreshSingleNodeWorkflowCheckIssues: (nodeId: string) => void; + /** 移除所有错误状态 */ onRemoveError: () => void; @@ -84,24 +94,39 @@ type WorkflowActionsContextValue = { setConnectingEdge: React.Dispatch>; }; export const WorkflowActionsContext = createContext({ - onUpdateNodeError: function (nodeId: string, isError: boolean): void { + onUpdateNodeError: (...args: Parameters) => { + void args; + throw new Error('Function not implemented.'); + }, + onSyncWorkflowCheckIssues: ( + ...args: Parameters + ) => { + void args; + throw new Error('Function not implemented.'); + }, + onRefreshSingleNodeWorkflowCheckIssues: ( + ...args: Parameters + ) => { + void args; throw new Error('Function not implemented.'); }, - onRemoveError: function (): void { + onRemoveError: () => { throw new Error('Function not implemented.'); }, - onResetNode: function (e: { id: string; node: FlowNodeTemplateType }): void { + onResetNode: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); }, - onChangeNode: function (props: FlowNodeChangeProps | FlowNodeChangeProps[]): void { + onChangeNode: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); }, - onDelEdge: function (e: { nodeId: string; sourceHandle?: string; targetHandle?: string }): void { + onDelEdge: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); }, - setConnectingEdge: function ( - value: React.SetStateAction - ): void { + setConnectingEdge: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); } }); @@ -114,10 +139,17 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const { toast } = useToast(); // 获取 WorkflowBufferDataContext 的数据 - const { forbiddenSaveSnapshot, setEdges, setNodes } = useContextSelector( - WorkflowBufferDataContext, - (v) => v - ); + const { + forbiddenSaveSnapshot: forbiddenSaveSnapshotRef, + setEdges, + setNodes, + edges, + getNodes + } = useContextSelector(WorkflowBufferDataContext, (v) => v); + + const singleNodeCheckTimerRef = useRef>>(new Map()); + const edgeCheckTimerRef = useRef | null>(null); + const isFirstEdgesEffectRef = useRef(true); // 连接状态 const [connectingEdge, setConnectingEdge] = useState(); @@ -143,7 +175,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod [setEdges] ); - // 更新节点错误状态 + // 更新节点错误状态;标红时仅保留一个节点的 isError,与原版 fail-fast 行为一致。 const onUpdateNodeError = useCallback( (nodeId: string, isError: boolean) => { setNodes((state) => @@ -151,34 +183,141 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod if (item.data?.nodeId === nodeId) { return { ...item, - selected: true, + selected: isError ? true : item.selected, data: { ...item.data, isError } }; } + + if (isError && item.data.isError) { + return { + ...item, + data: { + ...item.data, + isError: false + } + }; + } + return item; }) ); }, [setNodes] ); - // 移除所有节点的错误状态 - const onRemoveError = useCallback(() => { - setNodes((state) => - state.map((item) => { - if (item.data.isError) { + + /** 同步节点下方问题文案;不改动 isError,标红仅由 onUpdateNodeError 控制。 */ + const onSyncWorkflowCheckIssues = useCallback( + (nodeIssueMap: WorkflowCheckNodeIssueMap) => { + setNodes((state) => + state.map((item) => { + const nodeId = item.data.nodeId; + const issues = nodeIssueMap[nodeId]; + const nextIssues = issues?.length ? issues : undefined; + + if (JSON.stringify(item.data.workflowCheckIssues) === JSON.stringify(nextIssues)) { + return item; + } + return { ...item, - selected: false, data: { ...item.data, - isError: false + workflowCheckIssues: nextIssues } }; + }) + ); + }, + [setNodes] + ); + + /** 单节点配置变更后防抖重校验,仅同步问题文案,不自动标红。 */ + const onRefreshSingleNodeWorkflowCheckIssues = useCallback( + ( + ...args: Parameters + ) => { + void args; + const nodes = getNodes(); + const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + onSyncWorkflowCheckIssues(issueMap); + }, + [edges, getNodes, onSyncWorkflowCheckIssues, t] + ); + + /** 节点配置变更后防抖触发单节点重新校验,避免每次输入都同步扫描。 */ + const scheduleSingleNodeWorkflowCheck = useCallback( + (nodeId: string) => { + const existingTimer = singleNodeCheckTimerRef.current.get(nodeId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + singleNodeCheckTimerRef.current.set( + nodeId, + setTimeout(() => { + singleNodeCheckTimerRef.current.delete(nodeId); + onRefreshSingleNodeWorkflowCheckIssues(nodeId); + }, 400) + ); + }, + [onRefreshSingleNodeWorkflowCheckIssues] + ); + + /** 连线变更后防抖全量扫描,及时更新 no_upstream 等依赖连线的错误态。 */ + const scheduleWorkflowCheckOnEdgeChange = useCallback(() => { + if (edgeCheckTimerRef.current) { + clearTimeout(edgeCheckTimerRef.current); + } + + edgeCheckTimerRef.current = setTimeout(() => { + edgeCheckTimerRef.current = null; + const nodes = getNodes(); + if (nodes.length === 0) return; + + const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + onSyncWorkflowCheckIssues(issueMap); + }, 400); + }, [edges, getNodes, onSyncWorkflowCheckIssues, t]); + + useEffect(() => { + if (isFirstEdgesEffectRef.current) { + isFirstEdgesEffectRef.current = false; + return; + } + + scheduleWorkflowCheckOnEdgeChange(); + }, [edges, scheduleWorkflowCheckOnEdgeChange]); + + useEffect(() => { + const timers = singleNodeCheckTimerRef.current; + return () => { + timers.forEach((timer) => clearTimeout(timer)); + timers.clear(); + if (edgeCheckTimerRef.current) { + clearTimeout(edgeCheckTimerRef.current); + } + }; + }, []); + + // 移除所有节点的错误状态 + const onRemoveError = useCallback(() => { + setNodes((state) => + state.map((item) => { + if (!item.data.isError && !item.data.workflowCheckIssues?.length) { + return item; } - return item; + return { + ...item, + selected: false, + data: { + ...item.data, + isError: false, + workflowCheckIssues: undefined + } + }; }) ); }, [setNodes]); @@ -187,7 +326,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const onResetNode = useCallback( ({ id, node }: Parameters[0]) => { // 确保重置时不阻塞快照保存 - forbiddenSaveSnapshot.current = false; + forbiddenSaveSnapshotRef.current = false; setNodes((state) => state.map((item) => { @@ -212,7 +351,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }) ); }, - [forbiddenSaveSnapshot, setNodes] + [forbiddenSaveSnapshotRef, setNodes] ); // 使用结构共享优化的节点更改 @@ -229,6 +368,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const onChangeNode = useCallback( (props: FlowNodeChangeProps | FlowNodeChangeProps[]) => { const updateData = Array.isArray(props) ? props : [props]; + const nodeIdsToRecheck = new Set(updateData.map((item) => item.nodeId)); setNodes((nodes) => { return nodes.map((node) => { @@ -347,14 +487,18 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }; }); }); + + nodeIdsToRecheck.forEach((nodeId) => scheduleSingleNodeWorkflowCheck(nodeId)); }, - [setNodes, toast, t, onDelEdge, llmModelMap] + [setNodes, toast, t, onDelEdge, llmModelMap, scheduleSingleNodeWorkflowCheck] ); const contextValue = useMemo(() => { console.log('WorkflowActionsContextValue 更新了'); return { onUpdateNodeError, + onSyncWorkflowCheckIssues, + onRefreshSingleNodeWorkflowCheckIssues, onRemoveError, onResetNode, onChangeNode, @@ -362,7 +506,16 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod connectingEdge, setConnectingEdge }; - }, [onUpdateNodeError, onRemoveError, onResetNode, onChangeNode, onDelEdge, connectingEdge]); + }, [ + onUpdateNodeError, + onSyncWorkflowCheckIssues, + onRefreshSingleNodeWorkflowCheckIssues, + onRemoveError, + onResetNode, + onChangeNode, + onDelEdge, + connectingEdge + ]); return ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx index c21ca6f86d0e..c72367f7437e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -1,11 +1,12 @@ // 工作流工具函数层 -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, + checkWorkflowBeforeRunOrPublish, + checkWorkflowNodeIssues, adaptCatchError, storeNode2FlowNode, storeEdge2RenderEdge @@ -67,47 +68,25 @@ type WorkflowUtilsContextValue = { }; }; export const WorkflowUtilsContext = createContext({ - initData: function ( - e: { - nodes: StoreNodeItemType[]; - edges: StoreEdgeItemType[]; - chatConfig?: AppChatConfigType; - }, - isInit?: boolean - ): Promise { + initData: (...args: Parameters) => { + 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 + ) => { + void args; throw new Error('Function not implemented.'); }, - splitOutput: function (outputs: FlowNodeOutputItemType[]): { - successOutputs: FlowNodeOutputItemType[]; - hiddenOutputs: FlowNodeOutputItemType[]; - errorOutputs: FlowNodeOutputItemType[]; - } { + splitOutput: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); }, - splitToolInputs: function ( - inputs: FlowNodeInputItemType[], - nodeId: string - ): { - isTool: boolean; - toolInputs: FlowNodeInputItemType[]; - commonInputs: FlowNodeInputItemType[]; - } { + splitToolInputs: (...args: Parameters) => { + void args; throw new Error('Function not implemented.'); } }); @@ -115,7 +94,7 @@ export const WorkflowUtilsContext = createContext({ 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 +106,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 ); @@ -222,21 +201,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', @@ -248,15 +238,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 ( diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 408b63bd0897..a3e0fba8191f 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -1,9 +1,13 @@ -import type { StoreNodeItemType, FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import type { + StoreNodeItemType, + FlowNodeItemType, + WorkflowCheckIssue, + WorkflowCheckNodeIssueMap +} from '@fastgpt/global/core/workflow/type/node'; import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; import type { Edge, Node, XYPosition } from 'reactflow'; import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; import { - AppNodeFlowNodeTypeMap, EDGE_TYPE, FlowNodeInputTypeEnum, FlowNodeOutputTypeEnum, @@ -41,6 +45,10 @@ import { workflowSystemVariables } from '../app/utils'; import type { WorkflowDataContextType } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type'; +import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; +import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin'; +import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; /* ====== node ======= */ /** @@ -563,11 +571,731 @@ export const getNodeAllSource = ({ }; /* ====== Connection ======= */ -// Connectivity check result type +type WorkflowCheckContext = { + nodeMap: Map>; + nodeOutputMap: Map>; + incomingEdgesMap: Map[]>; + outgoingEdgesMap: Map[]>; + reachableNodeSet: Set; +}; + +const workflowCheckSkipConnectionTypes = new Set([ + FlowNodeTypeEnum.systemConfig, + FlowNodeTypeEnum.pluginConfig, + FlowNodeTypeEnum.comment, + FlowNodeTypeEnum.globalVariable, + FlowNodeTypeEnum.emptyNode +]); + +const workflowCheckStartTypes = new Set([ + FlowNodeTypeEnum.workflowStart, + FlowNodeTypeEnum.pluginInput, + FlowNodeTypeEnum.nestedStart, + FlowNodeTypeEnum.loopRunStart +]); + +const workflowCheckSkipNodeRuleTypes = new Set([ + FlowNodeTypeEnum.systemConfig, + FlowNodeTypeEnum.pluginConfig, + FlowNodeTypeEnum.pluginInput, + FlowNodeTypeEnum.workflowStart, + FlowNodeTypeEnum.comment +]); + +const isEmptyWorkflowInputValue = (value: unknown) => + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0); + +/** 优先取 label,空则取 debugLabel,并走 i18n 翻译,避免展示 quoteQA 等内部 key。 */ +const getInputLabel = (input: FlowNodeInputItemType, t?: TFunction) => { + const rawLabel = + (typeof input.label === 'string' && input.label ? input.label : undefined) || + (typeof input.debugLabel === 'string' && input.debugLabel ? input.debugLabel : undefined) || + input.key; + + if (t && rawLabel) { + return t(rawLabel as any); + } + + return rawLabel; +}; + +/** 设计稿固定提示文案 code,与 issue.code 一一对应或作为文案模板。 */ +type WorkflowCheckMessageCode = + | 'required_input_empty' + | 'no_upstream' + | 'invalid_reference' + | 'if_else_incomplete' + | 'user_select_empty' + | 'user_select_value_empty' + | 'form_input_empty' + | 'classify_question_empty' + | 'classify_question_value_empty' + | 'code_input_incomplete' + | 'http_url_empty' + | 'context_extract_empty' + | 'tool_call_empty' + | 'tool_inactive' + | 'tool_missing' + | 'tool_no_permission' + | 'tool_offline'; + +/** issue.code -> 设计稿固定文案 code。表外 code 映射到最接近的文案,见 TODO 注释。 */ +const WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP: Record = { + required_input_empty: 'required_input_empty', + no_upstream: 'no_upstream', + isolated_node: 'no_upstream', + unreachable_from_start: 'no_upstream', + invalid_reference: 'invalid_reference', + if_else_incomplete: 'if_else_incomplete', + user_select_empty: 'user_select_empty', + user_select_value_empty: 'user_select_value_empty', + form_input_empty: 'form_input_empty', + classify_question_empty: 'classify_question_empty', + classify_question_value_empty: 'classify_question_value_empty', + code_input_incomplete: 'code_input_incomplete', + http_url_empty: 'http_url_empty', + context_extract_empty: 'context_extract_empty', + tool_call_empty: 'tool_call_empty', + tool_inactive: 'tool_inactive', + tool_missing: 'tool_missing', + tool_no_permission: 'tool_no_permission', + tool_offline: 'tool_offline', + // TODO: 设计稿未覆盖,暂映射到最接近的「配置不完整」文案,待产品确认。 + loop_run_missing_break: 'if_else_incomplete', + variable_update_incomplete: 'code_input_incomplete' +}; + +/** 待处理:引用无效、工具不存在、工具已停用。其余均为待完善。 */ +export const WORKFLOW_CHECK_PENDING_HANDLE_CODES = new Set([ + 'invalid_reference', + 'tool_missing', + 'tool_no_permission', + 'tool_offline' +]); + +export type WorkflowCheckUIStatus = 'pending_improve' | 'pending_handle'; + +/** 按 issue code 映射 UI 状态前缀,不直接使用 level 字段。 */ +export const getWorkflowCheckIssueUIStatus = (code: string): WorkflowCheckUIStatus => + WORKFLOW_CHECK_PENDING_HANDLE_CODES.has(code) ? 'pending_handle' : 'pending_improve'; + +const workflowCheckMessageFallback: Record< + WorkflowCheckMessageCode, + (params?: { inputName?: string }) => string +> = { + required_input_empty: ({ inputName } = {}) => `需填写必填项 ${inputName ?? ''}`.trim(), + no_upstream: () => '未与其他节点连线', + invalid_reference: ({ inputName } = {}) => `${inputName ?? ''} 引用了无效变量,需删除`.trim(), + if_else_incomplete: () => '存在未完成的条件配置,请完善', + user_select_empty: () => '需配置至少一个选项', + user_select_value_empty: () => '选项不可为空', + form_input_empty: () => '需配置至少一个字段', + classify_question_empty: () => '需配置至少一个分类', + classify_question_value_empty: () => '分类值不可为空', + code_input_incomplete: () => '存在未完成的输入变量配置,请完善', + http_url_empty: () => '需配置请求地址', + context_extract_empty: () => '需配置至少一个目标字段', + tool_call_empty: () => '需配置工具或开启虚拟机', + tool_inactive: () => '该工具尚未激活,请激活使用', + tool_missing: () => '该工具不存在,请删除', + tool_no_permission: () => '当前账号无权限访问该资源', + tool_offline: () => '该工具已停用,请删除' +}; + +const PLUGIN_DATA_PERMISSION_ERROR_CODES = new Set([ + AppErrEnum.unAuthApp, + PluginErrEnum.unAuth +]); + +const PLUGIN_DATA_MISSING_ERROR_CODES = new Set([ + AppErrEnum.unExist, + PluginErrEnum.unExist +]); + +/** pluginData.error 可能是 statusText 或 getErrText 翻译后的 message,需两种都识别。 */ +const resolvePluginDataErrorIssueCode = (error: string): WorkflowCheckMessageCode => { + if ( + PLUGIN_DATA_PERMISSION_ERROR_CODES.has(error) || + error === ERROR_RESPONSE[AppErrEnum.unAuthApp]?.message || + error === ERROR_RESPONSE[PluginErrEnum.unAuth]?.message + ) { + return 'tool_no_permission'; + } + + if ( + PLUGIN_DATA_MISSING_ERROR_CODES.has(error) || + error === ERROR_RESPONSE[AppErrEnum.unExist]?.message || + error === ERROR_RESPONSE[PluginErrEnum.unExist]?.message + ) { + return 'tool_missing'; + } + + return 'tool_missing'; +}; + +const resolveWorkflowCheckMessageCode = (issueCode: string): WorkflowCheckMessageCode | undefined => + WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP[issueCode]; + +/** 根据 issue.code 返回设计稿固定提示文案,不自由拼接或生成表外文案。 */ +export const getWorkflowCheckIssueMessage = ( + issueCode: string, + t?: TFunction, + params?: { inputName?: string } +) => { + const messageCode = resolveWorkflowCheckMessageCode(issueCode); + if (!messageCode) return ''; + + if (t) { + return t(`common:core.workflow.check.${messageCode}`, params); + } + return workflowCheckMessageFallback[messageCode](params); +}; + +const createWorkflowCheckContext = ({ + nodes, + edges +}: { + nodes: Node[]; + edges: Edge[]; +}): WorkflowCheckContext => { + const nodeMap = new Map>(); + const nodeOutputMap = new Map>(); + const incomingEdgesMap = new Map[]>(); + const outgoingEdgesMap = new Map[]>(); + + nodes.forEach((node) => { + nodeMap.set(node.data.nodeId, node); + nodeOutputMap.set(node.data.nodeId, new Set(node.data.outputs.map((output) => output.id))); + incomingEdgesMap.set(node.data.nodeId, []); + outgoingEdgesMap.set(node.data.nodeId, []); + }); + + edges.forEach((edge) => { + outgoingEdgesMap.get(edge.source)?.push(edge); + incomingEdgesMap.get(edge.target)?.push(edge); + }); + + const reachableNodeSet = new Set(); + const visit = (nodeId: string) => { + if (reachableNodeSet.has(nodeId)) return; + reachableNodeSet.add(nodeId); + + outgoingEdgesMap.get(nodeId)?.forEach((edge) => visit(edge.target)); + }; + + nodes.forEach((node) => { + if ( + node.data.flowNodeType === FlowNodeTypeEnum.workflowStart || + node.data.flowNodeType === FlowNodeTypeEnum.pluginInput || + node.data.flowNodeType === FlowNodeTypeEnum.nestedStart || + node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart + ) { + visit(node.data.nodeId); + } + }); + + return { + nodeMap, + nodeOutputMap, + incomingEdgesMap, + outgoingEdgesMap, + reachableNodeSet + }; +}; + +const referenceValueIsLive = ( + value: ReferenceItemValueType | undefined, + context: WorkflowCheckContext +) => { + if (!isValidReferenceValueFormat(value)) return false; + const [refNodeId, refOutputId] = value; + if (!refNodeId || !refOutputId) return false; + if (refNodeId === VARIABLE_NODE_ID) return true; + + return context.nodeOutputMap.get(refNodeId)?.has(refOutputId) === true; +}; + +/** 引用输入是否尚未选择(空占位 / 未选变量),区别于曾经选中但已失效的引用。 */ +const isUnsetReferenceValue = (value: unknown) => { + if (value === undefined || value === null || value === '') return true; + if (!Array.isArray(value)) return true; + if (value.length === 0) return true; + + // 单引用 [nodeId, outputId];占位符 ['', ''] 或格式不完整均视为未选择 + if (value.length === 2 && !Array.isArray(value[0])) { + const [refNodeId, refOutputId] = value; + if (typeof refNodeId !== 'string') return true; + return !refNodeId || !refOutputId; + } + + return false; +}; + +/** 引用曾经有效配置过,但目标节点或输出已不存在(如上游节点被删除)。 */ +const isStaleReferenceValue = (value: unknown, context: WorkflowCheckContext) => { + if (!isValidReferenceValueFormat(value)) return false; + const [refNodeId, refOutputId] = value; + if (!refNodeId || !refOutputId) return false; + return !referenceValueIsLive(value as ReferenceItemValueType, context); +}; + +const isEmptyReferenceInputValue = (value: unknown, isArrayType: boolean) => { + if (isArrayType) { + return !Array.isArray(value) || value.length === 0; + } + return isUnsetReferenceValue(value); +}; + +/** + * 结构化校验工作流节点和连线。 + * 函数只读取入参并返回每个节点的错误列表,调用方负责写入 React state、toast 或定位画布。 + */ +export const checkWorkflowNodeIssues = ({ + nodes, + edges, + nodeId, + t +}: { + nodes: Node[]; + edges: Edge[]; + nodeId?: string; + t?: TFunction; +}): WorkflowCheckNodeIssueMap => { + const context = createWorkflowCheckContext({ nodes, edges }); + const issueMap: WorkflowCheckNodeIssueMap = {}; + const nodeIds = nodes.map((node) => node.data.nodeId); + const targetNodes = nodeId ? nodes.filter((node) => node.data.nodeId === nodeId) : nodes; + + const addIssue = ({ + node, + code, + message, + inputKey + }: { + node: Node; + code: string; + message: string; + inputKey?: string; + }) => { + const issue: WorkflowCheckIssue = { + nodeId: node.data.nodeId, + nodeName: node.data.name, + nodeType: node.data.flowNodeType, + level: 'error', + code, + message, + inputKey + }; + issueMap[node.data.nodeId] = [...(issueMap[node.data.nodeId] ?? []), issue]; + }; + + for (const node of targetNodes) { + const data = node.data; + const inputs = data.inputs; + const inputMap = new Map(inputs.map((input) => [input.key, input])); + const isToolNode = context.incomingEdgesMap + .get(data.nodeId) + ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); + + if (data.pluginData?.error) { + const issueCode = resolvePluginDataErrorIssueCode(data.pluginData.error); + addIssue({ + node, + code: issueCode, + message: getWorkflowCheckIssueMessage(issueCode, t) + }); + } + + const status = data.status ?? data.pluginData?.status; + if (status === PluginStatusEnum.Offline) { + addIssue({ + node, + code: 'tool_offline', + message: getWorkflowCheckIssueMessage('tool_offline', t) + }); + } + + if (data.pluginData && data.isLatestVersion === false) { + addIssue({ + node, + code: 'tool_inactive', + message: getWorkflowCheckIssueMessage('tool_inactive', t) + }); + } + + if (!workflowCheckSkipNodeRuleTypes.has(data.flowNodeType)) { + if (data.flowNodeType === FlowNodeTypeEnum.ifElseNode) { + const ifElseList = inputMap.get(NodeInputKeyEnum.ifElseList)?.value as + | IfElseListItemType[] + | undefined; + const hasIncompleteCondition = (ifElseList ?? []).some((item) => + item.list.some( + (listItem) => + listItem.variable === undefined || + listItem.condition === undefined || + (listItem.value === undefined && + listItem.condition !== VariableConditionEnum.isEmpty && + listItem.condition !== VariableConditionEnum.isNotEmpty) + ) + ); + + if (!ifElseList || hasIncompleteCondition) { + addIssue({ + node, + code: 'if_else_incomplete', + message: getWorkflowCheckIssueMessage('if_else_incomplete', t), + inputKey: NodeInputKeyEnum.ifElseList + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.userSelect) { + const configValue = inputMap.get(NodeInputKeyEnum.userSelectOptions)?.value as + | Array<{ value?: string }> + | undefined; + if (!configValue || configValue.length === 0) { + addIssue({ + node, + code: 'user_select_empty', + message: getWorkflowCheckIssueMessage('user_select_empty', t), + inputKey: NodeInputKeyEnum.userSelectOptions + }); + } else if (configValue.some((item) => !item.value)) { + addIssue({ + node, + code: 'user_select_value_empty', + message: getWorkflowCheckIssueMessage('user_select_value_empty', t), + inputKey: NodeInputKeyEnum.userSelectOptions + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.formInput) { + const value = inputMap.get(NodeInputKeyEnum.userInputForms)?.value as unknown[] | undefined; + if (!value || value.length === 0) { + addIssue({ + node, + code: 'form_input_empty', + message: getWorkflowCheckIssueMessage('form_input_empty', t), + inputKey: NodeInputKeyEnum.userInputForms + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.classifyQuestion) { + const agents = inputMap.get(NodeInputKeyEnum.agents)?.value as + | Array<{ value?: string; key?: string }> + | undefined; + if (!agents || agents.length === 0) { + addIssue({ + node, + code: 'classify_question_empty', + message: getWorkflowCheckIssueMessage('classify_question_empty', t), + inputKey: NodeInputKeyEnum.agents + }); + } else if (agents.some((item) => !item.value && !item.key)) { + addIssue({ + node, + code: 'classify_question_value_empty', + message: getWorkflowCheckIssueMessage('classify_question_value_empty', t), + inputKey: NodeInputKeyEnum.agents + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.code) { + const hasIncompleteDynamicInput = inputs.some((input) => { + if ( + [ + NodeInputKeyEnum.code, + NodeInputKeyEnum.codeType, + NodeInputKeyEnum.addInputParam + ].includes(input.key as NodeInputKeyEnum) + ) { + return false; + } + return !input.key || !input.label; + }); + if (hasIncompleteDynamicInput) { + addIssue({ + node, + code: 'code_input_incomplete', + message: getWorkflowCheckIssueMessage('code_input_incomplete', t) + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.httpRequest468) { + const urlInput = inputMap.get(NodeInputKeyEnum.httpReqUrl); + if (isEmptyWorkflowInputValue(urlInput?.value)) { + addIssue({ + node, + code: 'http_url_empty', + message: getWorkflowCheckIssueMessage('http_url_empty', t), + inputKey: NodeInputKeyEnum.httpReqUrl + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.contentExtract) { + const extractKeys = inputMap.get(NodeInputKeyEnum.extractKeys)?.value as + | unknown[] + | undefined; + if (!extractKeys || extractKeys.length === 0) { + addIssue({ + node, + code: 'context_extract_empty', + message: getWorkflowCheckIssueMessage('context_extract_empty', t), + inputKey: NodeInputKeyEnum.extractKeys + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.loopRun) { + const mode = inputMap.get(NodeInputKeyEnum.loopRunMode)?.value as + | LoopRunModeEnum + | undefined; + if (mode === LoopRunModeEnum.conditional) { + const children = + (inputMap.get(NodeInputKeyEnum.childrenNodeIdList)?.value as string[]) ?? []; + const childSet = new Set(children); + const hasBreak = nodes.some( + (n) => + childSet.has(n.data.nodeId) && n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak + ); + if (!hasBreak) { + addIssue({ + node, + code: 'loop_run_missing_break', + message: getWorkflowCheckIssueMessage('loop_run_missing_break', t) + }); + } + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.toolCall) { + const toolConnections = context.outgoingEdgesMap + .get(data.nodeId) + ?.filter((edge) => edge.sourceHandle === NodeOutputKeyEnum.selectedTools); + const useAgentSandbox = inputMap.get(NodeInputKeyEnum.useAgentSandbox)?.value; + if ((toolConnections?.length ?? 0) === 0 && !useAgentSandbox) { + addIssue({ + node, + code: 'tool_call_empty', + message: getWorkflowCheckIssueMessage('tool_call_empty', t), + inputKey: NodeInputKeyEnum.useAgentSandbox + }); + } + } + + if (data.flowNodeType === FlowNodeTypeEnum.variableUpdate) { + const updateList = inputMap.get(NodeInputKeyEnum.updateList)?.value as + | TUpdateListItem[] + | undefined; + const updateListInvalid = + !updateList || + updateList.length === 0 || + updateList.some((item) => { + if ( + !isValidReferenceValue(item.variable, nodeIds) || + !referenceValueIsLive(item.variable, context) + ) { + return true; + } + + if (item.renderType === FlowNodeInputTypeEnum.reference) { + // 接受单引用 [ref] 与引用数组 [[ref], ...],与 dispatcher 对齐。 + if (isValidReferenceValueFormat(item.value)) { + return !referenceValueIsLive(item.value as ReferenceItemValueType, context); + } + return ( + !Array.isArray(item.value) || + item.value.length === 0 || + (item.value as ReferenceItemValueType[]).some( + (v) => !referenceValueIsLive(v, context) + ) + ); + } + + // input 模式:clear / boolean 由模式字段决定,不读 value。 + if (item.arrayMode === 'clear') return false; + if (item.booleanMode) return false; + const inputVal = item.value?.[1]; + return inputVal === undefined || inputVal === null || inputVal === ''; + }); + + if (updateListInvalid) { + addIssue({ + node, + code: 'variable_update_incomplete', + message: getWorkflowCheckIssueMessage('variable_update_incomplete', t), + inputKey: NodeInputKeyEnum.updateList + }); + } + } + + inputs.forEach((input) => { + if (input.key === NodeInputKeyEnum.loopRunInputArray) { + const loopRunMode = inputMap.get(NodeInputKeyEnum.loopRunMode)?.value as + | LoopRunModeEnum + | undefined; + if ( + data.flowNodeType === FlowNodeTypeEnum.loopRun && + loopRunMode === LoopRunModeEnum.conditional + ) { + return; + } + } + + if ( + !input.valueType || + [WorkflowIOValueTypeEnum.any, WorkflowIOValueTypeEnum.boolean].includes(input.valueType) + ) { + return; + } + + if (isToolNode && input.toolDescription) { + return; + } + + const isReferenceInput = nodeInputIsReference(input); + const isArrayReference = isReferenceInput && !!input.valueType?.startsWith('array'); + const inputValueIsEmpty = isReferenceInput + ? isEmptyReferenceInputValue(input.value, isArrayReference) + : isEmptyWorkflowInputValue(input.value); + + if (input.required && inputValueIsEmpty) { + addIssue({ + node, + code: 'required_input_empty', + message: getWorkflowCheckIssueMessage('required_input_empty', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); + } + + if (isReferenceInput) { + if (isArrayReference) { + const value = Array.isArray(input.value) ? input.value : []; + const hasStaleReference = value.some((item) => isStaleReferenceValue(item, context)); + + if (hasStaleReference) { + addIssue({ + node, + code: 'invalid_reference', + message: getWorkflowCheckIssueMessage('invalid_reference', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); + } + } else if (isStaleReferenceValue(input.value, context)) { + addIssue({ + node, + code: 'invalid_reference', + message: getWorkflowCheckIssueMessage('invalid_reference', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); + } + } + }); + } + + if (!workflowCheckSkipConnectionTypes.has(data.flowNodeType)) { + const isStartNode = workflowCheckStartTypes.has(data.flowNodeType); + const incomingEdges = context.incomingEdgesMap.get(data.nodeId) ?? []; + const outgoingEdges = context.outgoingEdgesMap.get(data.nodeId) ?? []; + const meaningfulOutgoingEdges = + data.flowNodeType === FlowNodeTypeEnum.toolCall + ? outgoingEdges.filter((edge) => edge.sourceHandle !== NodeOutputKeyEnum.selectedTools) + : outgoingEdges; + const hasAnyMeaningfulEdge = incomingEdges.length > 0 || meaningfulOutgoingEdges.length > 0; + + if (!isStartNode && incomingEdges.length === 0) { + addIssue({ + node, + code: 'no_upstream', + message: getWorkflowCheckIssueMessage('no_upstream', t) + }); + } else if (!isStartNode && !context.reachableNodeSet.has(data.nodeId)) { + addIssue({ + node, + code: 'unreachable_from_start', + message: getWorkflowCheckIssueMessage('unreachable_from_start', t) + }); + } else if (!hasAnyMeaningfulEdge) { + addIssue({ + node, + code: 'isolated_node', + message: getWorkflowCheckIssueMessage('isolated_node', t) + }); + } + } + } + + return issueMap; +}; + +export const checkWorkflowHasError = (nodeIssueMap: WorkflowCheckNodeIssueMap) => + Object.values(nodeIssueMap).some((issues) => issues.some((issue) => issue.level === 'error')); + +/** 返回存在 error 的 nodeId 列表;传入 nodeOrder 时按画布节点顺序排列,便于稳定定位第一个错误节点。 */ +export const getWorkflowCheckErrorNodeIds = ( + nodeIssueMap: WorkflowCheckNodeIssueMap, + nodeOrder?: string[] +) => { + const errorNodeIdSet = new Set( + Object.entries(nodeIssueMap) + .filter(([, issues]) => issues.some((issue) => issue.level === 'error')) + .map(([nodeId]) => nodeId) + ); + + if (nodeOrder) { + return nodeOrder.filter((nodeId) => errorNodeIdSet.has(nodeId)); + } + + return [...errorNodeIdSet]; +}; + +/** 运行/发布前全量扫描,并按画布节点顺序返回第一个 error 节点。 */ +export const checkWorkflowBeforeRunOrPublish = ({ + nodes, + edges, + t +}: { + nodes: Node[]; + edges: Edge[]; + t?: TFunction; +}) => { + const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + const nodeOrder = nodes.map((node) => node.data.nodeId); + const errorNodeIds = getWorkflowCheckErrorNodeIds(issueMap, nodeOrder); + + return { + issueMap, + hasError: errorNodeIds.length > 0, + firstErrorNodeId: errorNodeIds[0], + errorNodeIds + }; +}; + +/* ====== Connection (fail-fast, run/publish 阻断用) ======= */ type ConnectivityIssue = { nodeId: string; issue: 'isolated' | 'no_input' | 'unreachable_from_start'; }; + +/** + * fail-fast 校验:命中第一个错误节点立即 return。 + * 阶段 1 逐节点检查配置/孤立;全部通过后才进入阶段 2 连通性检查。 + * 运行/发布阻断与 fitView 定位依赖此顺序,勿改为全量扫描。 + */ export const checkWorkflowNodeAndConnection = ({ nodes, edges @@ -575,7 +1303,6 @@ export const checkWorkflowNodeAndConnection = ({ nodes: Node[]; edges: Edge[]; }): string[] | undefined => { - // Node check for (const node of nodes) { const data = node.data; const inputs = data.inputs; @@ -681,7 +1408,7 @@ export const checkWorkflowNodeAndConnection = ({ if (!refNodeId || !refOutputId) return false; if (refNodeId === VARIABLE_NODE_ID) return true; return !!nodes - .find((node) => node.data.nodeId === refNodeId) + .find((item) => item.data.nodeId === refNodeId) ?.data.outputs.find((output) => output.id === refOutputId); }; if ( @@ -692,7 +1419,6 @@ export const checkWorkflowNodeAndConnection = ({ return true; if (item.renderType === FlowNodeInputTypeEnum.reference) { - // 接受单引用 [ref] 与引用数组 [[ref], ...],与 dispatcher 对齐 if (isValidReferenceValueFormat(item.value)) { return !isLiveReference(item.value as ReferenceItemValueType); } @@ -703,7 +1429,6 @@ export const checkWorkflowNodeAndConnection = ({ ); } - // input 模式:clear / boolean 由模式字段决定,不读 value if (item.arrayMode === 'clear') return false; if (item.booleanMode) return false; const inputVal = item.value?.[1]; @@ -718,8 +1443,6 @@ export const checkWorkflowNodeAndConnection = ({ if ( inputs.some((input) => { - // Conditional loopRun hides loopRunInputArray in the UI; its required flag is - // only meaningful in array mode, so skip it here to avoid spurious failures. if (input.key === NodeInputKeyEnum.loopRunInputArray) { const loopRunMode = data.flowNodeType === FlowNodeTypeEnum.loopRun @@ -737,7 +1460,6 @@ export const checkWorkflowNodeAndConnection = ({ ) { return false; } - // check is tool input if (isToolNode && input.toolDescription) { return false; } @@ -748,9 +1470,7 @@ export const checkWorkflowNodeAndConnection = ({ } if (Array.isArray(input.value) && input.value.length === 0) return true; } - // check reference invalid if (nodeInputIsReference(input)) { - // 无效引用时,返回 true const checkValueValid = (value: ReferenceItemValueType) => { const nodeId = value?.[0]; const outputId = value?.[1]; @@ -762,18 +1482,16 @@ export const checkWorkflowNodeAndConnection = ({ } return !!nodes - .find((node) => node.data.nodeId === nodeId) + .find((item) => item.data.nodeId === nodeId) ?.data.outputs.find((output) => output.id === outputId); }; if (input.valueType?.startsWith('array')) { input.value = input.value ?? []; - // 如果内容为空,则报错 if (input.required && input.value.length === 0) { return true; } } else { - // Single reference if (input.required) { return !checkValueValid(input.value); } @@ -785,7 +1503,6 @@ export const checkWorkflowNodeAndConnection = ({ return [data.nodeId]; } - // Check node has invalid edge const edgeFilted = edges.filter( (edge) => !( @@ -793,7 +1510,6 @@ export const checkWorkflowNodeAndConnection = ({ edge.sourceHandle === NodeOutputKeyEnum.selectedTools ) ); - // Check node has edge const hasEdge = edgeFilted.some( (edge) => edge.source === data.nodeId || edge.target === data.nodeId ); @@ -802,39 +1518,30 @@ export const checkWorkflowNodeAndConnection = ({ } } - // Edge check - - /** - * Check graph connectivity and identify connectivity issues - */ const checkConnectivity = ( - nodes: Node[], - edges: Edge[] + connectivityNodes: Node[], + connectivityEdges: Edge[] ): string[] => { - // Find start node - const startNode = nodes.find( - (node) => - node.data.flowNodeType === FlowNodeTypeEnum.workflowStart || - node.data.flowNodeType === FlowNodeTypeEnum.pluginInput + const startNode = connectivityNodes.find( + (item) => + item.data.flowNodeType === FlowNodeTypeEnum.workflowStart || + item.data.flowNodeType === FlowNodeTypeEnum.pluginInput ); if (!startNode) { - // No start node found - this is a critical issue - return nodes.map((node) => node.data.nodeId); + return connectivityNodes.map((item) => item.data.nodeId); } const issues: ConnectivityIssue[] = []; - - // Build adjacency lists for both directions const outgoing = new Map(); const incoming = new Map(); - nodes.forEach((node) => { - outgoing.set(node.data.nodeId, []); - incoming.set(node.data.nodeId, []); + connectivityNodes.forEach((item) => { + outgoing.set(item.data.nodeId, []); + incoming.set(item.data.nodeId, []); }); - edges.forEach((edge) => { + connectivityEdges.forEach((edge) => { const outList = outgoing.get(edge.source) || []; outList.push(edge.target); outgoing.set(edge.source, outList); @@ -844,7 +1551,6 @@ export const checkWorkflowNodeAndConnection = ({ incoming.set(edge.target, inList); }); - // Check reachability from start node(Start node/Loop start 可以到达的地方) const reachableFromStart = new Set(); const dfsFromStart = (nodeId: string) => { if (reachableFromStart.has(nodeId)) return; @@ -854,21 +1560,19 @@ export const checkWorkflowNodeAndConnection = ({ neighbors.forEach((neighbor) => dfsFromStart(neighbor)); }; dfsFromStart(startNode.data.nodeId); - nodes.forEach((node) => { + connectivityNodes.forEach((item) => { if ( - node.data.flowNodeType === FlowNodeTypeEnum.nestedStart || - node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart + item.data.flowNodeType === FlowNodeTypeEnum.nestedStart || + item.data.flowNodeType === FlowNodeTypeEnum.loopRunStart ) { - dfsFromStart(node.data.nodeId); + dfsFromStart(item.data.nodeId); } }); - // Check each node for connectivity issues - for (const node of nodes) { - const nodeId = node.data.nodeId; - const nodeType = node.data.flowNodeType; + for (const item of connectivityNodes) { + const nodeId = item.data.nodeId; + const nodeType = item.data.flowNodeType; - // Skip system nodes that don't need connectivity checks if ( nodeType === FlowNodeTypeEnum.systemConfig || nodeType === FlowNodeTypeEnum.pluginConfig || @@ -879,8 +1583,6 @@ export const checkWorkflowNodeAndConnection = ({ continue; } - const hasIncoming = (incoming.get(nodeId) || []).length > 0; - const hasOutgoing = (outgoing.get(nodeId) || []).length > 0; const isStartNode = [ FlowNodeTypeEnum.workflowStart, FlowNodeTypeEnum.pluginInput, @@ -888,7 +1590,6 @@ export const checkWorkflowNodeAndConnection = ({ FlowNodeTypeEnum.loopRunStart ].includes(nodeType); - // Check if node is reachable from start if (!isStartNode && !reachableFromStart.has(nodeId)) { issues.push({ nodeId, diff --git a/projects/app/test/web/core/app/workflow/utils.test.ts b/projects/app/test/web/core/app/workflow/utils.test.ts index 56083ff0f7c0..9625973e6d22 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -20,10 +20,18 @@ import { filterWorkflowNodeOutputsByType, filterSelectableWorkflowNodeOutputs, workflowReferenceValueIsSelectable, - checkWorkflowNodeAndConnection + checkWorkflowNodeIssues, + checkWorkflowNodeAndConnection, + checkWorkflowHasError, + checkWorkflowBeforeRunOrPublish, + getWorkflowCheckErrorNodeIds } from '@/web/core/workflow/utils'; import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; import { NodeOutputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants'; +import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type'; +import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; +import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin'; +import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; describe('nodeTemplate2FlowNode', () => { it('should convert template to flow node', () => { @@ -58,6 +66,609 @@ describe('nodeTemplate2FlowNode', () => { }); }); +describe('checkWorkflowNodeIssues', () => { + const makeNode = ( + nodeId: string, + flowNodeType: FlowNodeTypeEnum, + data?: Partial + ): Node => + ({ + id: nodeId, + type: flowNodeType, + position: { x: 0, y: 0 }, + data: { + nodeId, + flowNodeType, + name: nodeId, + inputs: [], + outputs: [], + ...data + } + }) as Node; + + const startNode = makeNode('start', FlowNodeTypeEnum.workflowStart, { + outputs: [ + { + id: NodeOutputKeyEnum.userChatInput, + key: NodeOutputKeyEnum.userChatInput, + label: 'question', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + + it('collects multiple node errors in one pass', () => { + const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + const formNode = makeNode('form', FlowNodeTypeEnum.formInput, { + inputs: [ + { + key: NodeInputKeyEnum.userInputForms, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, requiredNode, formNode], + edges: [ + { id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'form', type: EDGE_TYPE } + ] + }); + + expect(Object.keys(result).sort()).toEqual(['form', 'required']); + expect(result.required.map((issue) => issue.code)).toContain('required_input_empty'); + expect(result.form.map((issue) => issue.code)).toContain('form_input_empty'); + }); + + it('keeps all errors on a single node', () => { + const node = makeNode('multi', FlowNodeTypeEnum.userSelect, { + inputs: [ + { + key: NodeInputKeyEnum.userSelectOptions, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [{ value: '' }] + }, + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'multi', type: EDGE_TYPE }] + }); + + expect(result.multi.map((issue) => issue.code)).toEqual( + expect.arrayContaining(['user_select_value_empty', 'required_input_empty']) + ); + }); + + it('reports nodes without upstream connections', () => { + const node = makeNode('orphan', FlowNodeTypeEnum.answerNode); + + const result = checkWorkflowNodeIssues({ nodes: [startNode, node], edges: [] }); + + expect(result.orphan.map((issue) => issue.code)).toContain('no_upstream'); + }); + + it('reports invalid references', () => { + const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['deleted', 'output'] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] + }); + + expect(result.ref.map((issue) => issue.code)).toContain('invalid_reference'); + }); + + it('reports plugin errors on node issues', () => { + const node = makeNode('tool', FlowNodeTypeEnum.appModule, { + pluginData: { + error: 'not found' + } as any + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }] + }); + + expect(result.tool.map((issue) => issue.code)).toContain('tool_missing'); + expect(result.tool[0]?.message).toBe('该工具不存在,请删除'); + }); + + it('reports permission error when pluginData error is unAuthApp', () => { + const node = makeNode('tool', FlowNodeTypeEnum.appModule, { + pluginData: { + error: AppErrEnum.unAuthApp + } as any + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }] + }); + + expect(result.tool.map((issue) => issue.code)).toContain('tool_no_permission'); + expect(result.tool[0]?.message).toBe('当前账号无权限访问该资源'); + }); + + it('reports permission error when pluginData error is translated message', () => { + const node = makeNode('tool', FlowNodeTypeEnum.pluginModule, { + pluginData: { + error: ERROR_RESPONSE[PluginErrEnum.unAuth].message + } as any + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }] + }); + + expect(result.tool.map((issue) => issue.code)).toContain('tool_no_permission'); + expect(result.tool[0]?.message).toBe('当前账号无权限访问该资源'); + }); + + it('reports missing tool when pluginData error is appUnExist', () => { + const node = makeNode('tool', FlowNodeTypeEnum.runApp, { + pluginData: { + error: AppErrEnum.unExist + } as any + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'tool', type: EDGE_TYPE }] + }); + + expect(result.tool.map((issue) => issue.code)).toContain('tool_missing'); + }); + + it('uses fixed design copy for mapped issue codes', () => { + const disconnectedNode = makeNode('disconnected', FlowNodeTypeEnum.answerNode); + const unreachableNode = makeNode('unreachable', FlowNodeTypeEnum.answerNode); + const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + + const unreachableResult = checkWorkflowNodeIssues({ + nodes: [startNode, disconnectedNode, unreachableNode], + edges: [{ id: 'e1', source: 'disconnected', target: 'unreachable', type: EDGE_TYPE }] + }); + const requiredResult = checkWorkflowNodeIssues({ + nodes: [startNode, requiredNode], + edges: [{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }] + }); + + expect(unreachableResult.unreachable[0]?.code).toBe('unreachable_from_start'); + expect(unreachableResult.unreachable[0]?.message).toBe('未与其他节点连线'); + expect(requiredResult.required[0]?.message).toBe('需填写必填项 answer'); + }); + + it('reports inactive and offline tools', () => { + const inactiveNode = makeNode('inactive', FlowNodeTypeEnum.appModule, { + pluginData: {} as any, + isLatestVersion: false + }); + const offlineNode = makeNode('offline', FlowNodeTypeEnum.appModule, { + status: PluginStatusEnum.Offline + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, inactiveNode, offlineNode], + edges: [ + { id: 'e1', source: 'start', target: 'inactive', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'offline', type: EDGE_TYPE } + ] + }); + + expect(result.inactive.map((issue) => issue.code)).toContain('tool_inactive'); + expect(result.offline.map((issue) => issue.code)).toContain('tool_offline'); + }); + + it('reports specific node configuration errors', () => { + const httpNode = makeNode('http', FlowNodeTypeEnum.httpRequest468, { + inputs: [ + { + key: NodeInputKeyEnum.httpReqUrl, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + const toolCallNode = makeNode('toolCall', FlowNodeTypeEnum.toolCall, { + inputs: [ + { + key: NodeInputKeyEnum.useAgentSandbox, + renderTypeList: [FlowNodeInputTypeEnum.switch], + value: false + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, httpNode, toolCallNode], + edges: [ + { id: 'e1', source: 'start', target: 'http', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'toolCall', type: EDGE_TYPE } + ] + }); + + expect(result.http.map((issue) => issue.code)).toContain('http_url_empty'); + expect(result.toolCall.map((issue) => issue.code)).toContain('tool_call_empty'); + }); + + it('returns invalid reference message with input name', () => { + const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['deleted', 'output'] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] + }); + + expect(result.ref[0]?.message).toBe('answer 引用了无效变量,需删除'); + }); + + it('treats unset reference as required_input_empty instead of invalid_reference', () => { + const unsetValues = [undefined, ['', ''], [undefined, undefined]] as const; + + unsetValues.forEach((value, index) => { + const node = makeNode(`empty-ref-${index}`, FlowNodeTypeEnum.chatNode, { + inputs: [ + { + key: NodeInputKeyEnum.userChatInput, + label: '用户问题', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedTypeIndex: 0, + value + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: `e-${index}`, source: 'start', target: node.id, type: EDGE_TYPE }] + }); + + const issueCodes = result[node.id]?.map((issue) => issue.code) ?? []; + expect(issueCodes).toContain('required_input_empty'); + expect(issueCodes).not.toContain('invalid_reference'); + }); + }); + + it('reports invalid_reference when referenced upstream node or output was deleted', () => { + const nodeWithDeletedNodeRef = makeNode('deleted-node', FlowNodeTypeEnum.chatNode, { + inputs: [ + { + key: NodeInputKeyEnum.userChatInput, + label: '用户问题', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedTypeIndex: 0, + value: ['deleted-node-id', NodeOutputKeyEnum.userChatInput] + } + ] + }); + const nodeWithDeletedOutputRef = makeNode('deleted-output', FlowNodeTypeEnum.chatNode, { + inputs: [ + { + key: NodeInputKeyEnum.userChatInput, + label: '用户问题', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedTypeIndex: 0, + value: ['start', 'deleted-output-id'] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeWithDeletedNodeRef, nodeWithDeletedOutputRef], + edges: [ + { id: 'e1', source: 'start', target: 'deleted-node', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'deleted-output', type: EDGE_TYPE } + ] + }); + + expect(result['deleted-node'].map((issue) => issue.code)).toContain('invalid_reference'); + expect(result['deleted-node'].map((issue) => issue.code)).not.toContain('required_input_empty'); + expect(result['deleted-output'].map((issue) => issue.code)).toContain('invalid_reference'); + expect(result['deleted-output'].map((issue) => issue.code)).not.toContain( + 'required_input_empty' + ); + }); + + it('reports specific node configuration errors for ifElse, classify, code and extract', () => { + const ifElseNode = makeNode('ifElse', FlowNodeTypeEnum.ifElseNode, { + inputs: [ + { + key: NodeInputKeyEnum.ifElseList, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [ + { + list: [{ variable: undefined, condition: undefined, value: undefined }] + } + ] + } + ] + }); + const classifyNode = makeNode('classify', FlowNodeTypeEnum.classifyQuestion, { + inputs: [ + { + key: NodeInputKeyEnum.agents, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [] + } + ] + }); + const codeNode = makeNode('code', FlowNodeTypeEnum.code, { + inputs: [ + { + key: 'customVar', + label: '', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + const extractNode = makeNode('extract', FlowNodeTypeEnum.contentExtract, { + inputs: [ + { + key: NodeInputKeyEnum.extractKeys, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, ifElseNode, classifyNode, codeNode, extractNode], + edges: [ + { id: 'e1', source: 'start', target: 'ifElse', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'classify', type: EDGE_TYPE }, + { id: 'e3', source: 'start', target: 'code', type: EDGE_TYPE }, + { id: 'e4', source: 'start', target: 'extract', type: EDGE_TYPE } + ] + }); + + expect(result.ifElse.map((issue) => issue.code)).toContain('if_else_incomplete'); + expect(result.classify.map((issue) => issue.code)).toContain('classify_question_empty'); + expect(result.code.map((issue) => issue.code)).toContain('code_input_incomplete'); + expect(result.extract.map((issue) => issue.code)).toContain('context_extract_empty'); + }); + + it('clears single node errors after configuration is fixed', () => { + const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: 'fixed answer' + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, requiredNode], + edges: [{ id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }], + nodeId: 'required' + }); + + expect(result.required).toBeUndefined(); + expect(checkWorkflowHasError(result)).toBe(false); + }); + + it('supports single node validation', () => { + const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + const formNode = makeNode('form', FlowNodeTypeEnum.formInput, { + inputs: [ + { + key: NodeInputKeyEnum.userInputForms, + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, requiredNode, formNode], + edges: [ + { id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'form', type: EDGE_TYPE } + ], + nodeId: 'required' + }); + + expect(Object.keys(result)).toEqual(['required']); + }); +}); + +describe('workflow check helpers', () => { + it('detects workflow errors from issue map', () => { + expect(checkWorkflowHasError({ node1: [{ level: 'error' } as any] })).toBe(true); + expect(checkWorkflowHasError({ node1: [{ level: 'warning' } as any] })).toBe(false); + expect(checkWorkflowHasError({})).toBe(false); + }); + + it('orders error node ids by canvas node order for stable first-error focus', () => { + const issueMap = { + nodeB: [{ level: 'error' } as any], + nodeA: [{ level: 'error' } as any] + }; + + expect(getWorkflowCheckErrorNodeIds(issueMap)).toEqual( + expect.arrayContaining(['nodeA', 'nodeB']) + ); + expect(getWorkflowCheckErrorNodeIds(issueMap, ['nodeA', 'nodeB', 'nodeC'])).toEqual([ + 'nodeA', + 'nodeB' + ]); + }); + + it('returns first error node by canvas order for run/publish checks', () => { + const makeNode = ( + nodeId: string, + flowNodeType: FlowNodeTypeEnum, + data?: Partial + ): Node => + ({ + id: nodeId, + type: flowNodeType, + position: { x: 0, y: 0 }, + data: { + nodeId, + flowNodeType, + name: nodeId, + inputs: [], + outputs: [], + ...data + } + }) as Node; + + const startNode = makeNode('start', FlowNodeTypeEnum.workflowStart); + const requiredNode = makeNode('required', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + const httpNode = makeNode('http', FlowNodeTypeEnum.httpRequest468, { + inputs: [ + { + key: NodeInputKeyEnum.httpReqUrl, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ] + }); + + const result = checkWorkflowBeforeRunOrPublish({ + nodes: [startNode, requiredNode, httpNode], + edges: [ + { id: 'e1', source: 'start', target: 'required', type: EDGE_TYPE }, + { id: 'e2', source: 'start', target: 'http', type: EDGE_TYPE } + ] + }); + + expect(result.hasError).toBe(true); + expect(result.firstErrorNodeId).toBe('required'); + expect(result.errorNodeIds).toEqual(['required', 'http']); + }); + + it('blocks run/publish style checks when any error exists', () => { + const httpNode = { + id: 'http', + type: FlowNodeTypeEnum.httpRequest468, + position: { x: 0, y: 0 }, + data: { + nodeId: 'http', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: NodeInputKeyEnum.httpReqUrl, + renderTypeList: [FlowNodeInputTypeEnum.input], + value: '' + } + ], + outputs: [] + } + } as Node; + const startNode = { + id: 'start', + type: FlowNodeTypeEnum.workflowStart, + position: { x: 0, y: 0 }, + data: { + nodeId: 'start', + flowNodeType: FlowNodeTypeEnum.workflowStart, + inputs: [], + outputs: [] + } + } as Node; + + const issueMap = checkWorkflowNodeIssues({ + nodes: [startNode, httpNode], + edges: [{ id: 'e1', source: 'start', target: 'http', type: EDGE_TYPE }] + }); + + expect(checkWorkflowHasError(issueMap)).toBe(true); + expect(getWorkflowCheckErrorNodeIds(issueMap, ['start', 'http'])).toEqual(['http']); + }); +}); + describe('storeNode2FlowNode', () => { it('should convert store node to flow node', () => { const storeNode: StoreNodeItemType = { @@ -514,7 +1125,7 @@ describe('checkWorkflowNodeAndConnection', () => { const loop = makeLoopRunNode(LoopRunModeEnum.array, ['start1']); // 数组模式下 loopRunInputArray 必填,填个非空 value 走通用校验 const arrInput = loop.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunInputArray)!; - arrInput.value = ['ws', 'userChatInput']; + arrInput.value = [[VARIABLE_NODE_ID, 'bar']]; const nodes = [workflowStart, loop, makeChild('start1', FlowNodeTypeEnum.loopRunStart)]; const result = checkWorkflowNodeAndConnection({ nodes,