From c4de40e4a84c788bbca8100379071750bdc16d06 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 26 Aug 2026 15:47:35 +0800 Subject: [PATCH 01/18] feat(workflow): invalid variable check Signed-off-by: Nixieboluo --- packages/web/i18n/en/common.json | 3 +- packages/web/i18n/ko-KR/common.json | 3 +- packages/web/i18n/zh-CN/common.json | 3 +- packages/web/i18n/zh-Hant/common.json | 3 +- .../app/detail/Edit/FormComponent/Header.tsx | 7 +- .../Flow/hooks/useDebug.tsx | 3 +- .../RenderInput/templates/Reference.tsx | 64 +++--- .../context/workflowActionsContext.tsx | 35 +++- .../context/workflowUtilsContext.tsx | 13 +- .../app/detail/WorkflowComponents/utils.ts | 96 +-------- .../src/web/core/workflow/workflowCheck.ts | 151 +++++++++++--- .../detail/WorkflowComponents/utils.test.ts | 13 +- .../test/web/core/app/workflow/utils.test.ts | 191 +++++++++++++++--- 13 files changed, 396 insertions(+), 189 deletions(-) diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0d00082de252..ff6f205884a8 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -651,8 +651,9 @@ "core.workflow.check.form_input_empty": "Configure at least one field", "core.workflow.check.http_url_empty": "Configure request URL", "core.workflow.check.if_else_incomplete": "Incomplete condition configuration, please complete", - "core.workflow.check.invalid_reference": "{{inputName}} cites an invalid Variable. Remove the citation.", + "core.workflow.check.invalid_reference": "{{inputName}} contains a deleted Variable. Action required.", "core.workflow.check.no_upstream": "Not connected to other nodes", + "core.workflow.check.reference_deleted": "Variable deleted", "core.workflow.check.required_input_empty": "Please fill in required field {{inputName}}", "core.workflow.check.status.pending_handle": "To handle", "core.workflow.check.status.pending_improve": "To complete", diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index b52821b5a565..6ca77e4ad572 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -649,8 +649,9 @@ "core.workflow.check.form_input_empty": "필드를 하나 이상 설정하세요", "core.workflow.check.http_url_empty": "요청 URL을 설정하세요", "core.workflow.check.if_else_incomplete": "완료되지 않은 조건 설정이 있습니다. 설정을 완료하세요", - "core.workflow.check.invalid_reference": "{{inputName}}에서 유효하지 않은 변수를 인용하고 있습니다. 해당 인용을 삭제하세요", + "core.workflow.check.invalid_reference": "{{inputName}}에 삭제된 변수가 있습니다. 처리가 필요합니다.", "core.workflow.check.no_upstream": "다른 노드와 연결되지 않았습니다", + "core.workflow.check.reference_deleted": "변수 삭제됨", "core.workflow.check.required_input_empty": "필수 항목 {{inputName}}을(를) 입력하세요", "core.workflow.check.status.pending_handle": "처리 대기", "core.workflow.check.status.pending_improve": "보완 필요", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 45ba4eb9fdd6..75ae4e99d9cd 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -651,8 +651,9 @@ "core.workflow.check.form_input_empty": "需配置至少一个字段", "core.workflow.check.http_url_empty": "需配置请求地址", "core.workflow.check.if_else_incomplete": "存在未完成的条件配置,请完善", - "core.workflow.check.invalid_reference": "{{inputName}} 引用了无效变量,需删除", + "core.workflow.check.invalid_reference": "{{inputName}} 中存在已被删除的变量,需处理", "core.workflow.check.no_upstream": "未与其他节点连线", + "core.workflow.check.reference_deleted": "变量已删除", "core.workflow.check.required_input_empty": "需填写必填项 {{inputName}}", "core.workflow.check.status.pending_handle": "待处理", "core.workflow.check.status.pending_improve": "待完善", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index eeaadaa92ee4..0e0281c502a6 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -651,8 +651,9 @@ "core.workflow.check.form_input_empty": "需配置至少一個字段", "core.workflow.check.http_url_empty": "需配置請求地址", "core.workflow.check.if_else_incomplete": "存在未完成的條件配置,請完善", - "core.workflow.check.invalid_reference": "{{inputName}} 引用了無效變量,需刪除", + "core.workflow.check.invalid_reference": "{{inputName}} 中存在已被刪除的變量,需處理", "core.workflow.check.no_upstream": "未與其他節點連線", + "core.workflow.check.reference_deleted": "變量已刪除", "core.workflow.check.required_input_empty": "需填寫必填項 {{inputName}}", "core.workflow.check.status.pending_handle": "待處理", "core.workflow.check.status.pending_improve": "待完善", diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/Header.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/Header.tsx index f099f7f5a660..209200272ea0 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/Header.tsx @@ -290,7 +290,12 @@ const Header = ({ ); const edges = storeEdges.map((item) => storeEdge2RenderEdge({ edge: item })); - const checkResults = checkWorkflowBeforeRunOrPublish({ nodes, edges, t }); + const checkResults = checkWorkflowBeforeRunOrPublish({ + nodes, + edges, + t, + chatConfig: appForm.chatConfig + }); if (checkResults.hasError) { toast({ 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 f8aa0f444783..0d342fd6de49 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 @@ -108,7 +108,8 @@ export const useDebug = () => { const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({ nodes, edges, - t: workflowT + t: workflowT, + chatConfig: appDetail.chatConfig }); if (!hasError) { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 34fca64f3d5f..a0150830b4e6 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -3,6 +3,7 @@ import type { RenderInputProps } from '../type'; import { Flex, Box, type ButtonProps, Grid } from '@chakra-ui/react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { getNodeAllSource, filterSelectableWorkflowNodeOutputs } from '@/web/core/workflow/utils'; +import { isConfiguredReferenceValue } from '@/web/core/workflow/workflowCheck'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import type { @@ -14,10 +15,7 @@ import dynamic from 'next/dynamic'; import { useContextSelector } from 'use-context-selector'; import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant'; import { AppContext } from '@/pageComponents/app/detail/context'; -import { - WorkflowBufferDataContext, - WorkflowNodeDataContext -} from '../../../../../context/workflowInitContext'; +import { WorkflowBufferDataContext } from '../../../../../context/workflowInitContext'; import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; @@ -184,6 +182,8 @@ const SingleReferenceSelector = ({ popDirection, ButtonProps }: SelectProps) => { + const { t } = useSafeTranslation(); + const getSelectValue = useCallback( (value: ReferenceValueType) => { if (!value) return []; @@ -219,6 +219,7 @@ const SingleReferenceSelector = ({ const selectorVal = value as ReferenceItemValueType; const [nodeName, outputName] = getSelectValue(selectorVal); const isValidSelect = nodeName && outputName; + const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; return ( ) : ( - - {placeholder} + + {isInvalidReference ? t('common:core.workflow.check.reference_deleted') : placeholder} ) } @@ -242,7 +247,7 @@ const SingleReferenceSelector = ({ ButtonProps={ButtonProps} /> ); - }, [ButtonProps, getSelectValue, list, onSelect, placeholder, popDirection, value]); + }, [ButtonProps, getSelectValue, list, onSelect, placeholder, popDirection, t, value]); return ItemSelector; }; @@ -253,6 +258,8 @@ const MultipleReferenceSelector = ({ onSelect, popDirection }: SelectProps) => { + const { t } = useSafeTranslation(); + const getSelectValue = useCallback( (value: ReferenceValueType) => { if (!value) return []; @@ -284,10 +291,6 @@ const MultipleReferenceSelector = ({ }); }, [getSelectValue, value]); - const invalidList = useMemo(() => { - return formatList.filter((item) => item.nodeName && item.outputName); - }, [formatList]); - useEffect(() => { // Adapt array type from old version if (Array.isArray(value) && typeof value[0] === 'string') { @@ -300,7 +303,7 @@ const MultipleReferenceSelector = ({ return ( 0 ? ( + formatList.length > 0 ? ( - {invalidList.map(({ nodeName, outputName }, index) => { + {formatList.map(({ rawValue, nodeName, outputName }, index) => { + const isValidReference = Boolean(nodeName && outputName); + const isInvalidReference = + isConfiguredReferenceValue(rawValue) && !isValidReference; return ( - {nodeName} - - {outputName} + {isValidReference ? ( + <> + {nodeName} + + {outputName} + + ) : isInvalidReference ? ( + + {t('common:core.workflow.check.reference_deleted')} + + ) : ( + {placeholder} + )} ); - }, [invalidList, list, onSelect, placeholder, popDirection, value]); + }, [formatList, list, onSelect, placeholder, popDirection, t, value]); return ArraySelector; }; 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 5e6ce92705a4..0996a7711419 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -18,6 +18,7 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; import { checkWorkflowNodeIssues } from '@/web/core/workflow/workflowCheck'; import { collectWorkflowStartAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { AppContext } from '@/pageComponents/app/detail/context'; type FlowNodeChangeProps = { nodeId: string } & ( | { @@ -145,6 +146,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod edges, getNodes } = useContextSelector(WorkflowBufferDataContext, (v) => v); + const appDetail = useContextSelector(AppContext, (v) => v.appDetail); const singleNodeCheckTimerRef = useRef>>(new Map()); const edgeCheckTimerRef = useRef | null>(null); @@ -238,7 +240,13 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const onRefreshSingleNodeWorkflowCheckIssues = useCallback( (nodeId: string) => { const nodes = getNodes(); - const issueMap = checkWorkflowNodeIssues({ nodes, edges, nodeId, t }); + const issueMap = checkWorkflowNodeIssues({ + nodes, + edges, + nodeId, + t, + chatConfig: appDetail.chatConfig + }); setNodes((state) => state.map((item) => { @@ -261,7 +269,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }) ); }, - [edges, getNodes, setNodes, t] + [appDetail.chatConfig, edges, getNodes, setNodes, t] ); /** 节点配置变更后防抖触发单节点重新校验,避免每次输入都同步扫描。 */ @@ -294,10 +302,15 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const nodes = getNodes(); if (nodes.length === 0) return; - const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + const issueMap = checkWorkflowNodeIssues({ + nodes, + edges, + t, + chatConfig: appDetail.chatConfig + }); onSyncWorkflowCheckIssues(issueMap); }, 400); - }, [edges, getNodes, onSyncWorkflowCheckIssues, t]); + }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t]); useEffect(() => { if (isFirstEdgesEffectRef.current) { @@ -550,7 +563,19 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }); }); - if (updateData.length > 1) { + // 变更影响「其他节点」上的引用有效性,单节点复查覆盖不到,需要升级为全量检查: + // - catchError 决定该节点的 error 输出是否可被引用; + // - 输出的增删改直接影响下游已选引用(addOutput 本身不会让旧引用失效, + // 但会触发 invalidCondition 重算,可能把兄弟输出置为 invalid); + // - delOutput/replaceOutput 还会删边,与 edges effect 的全量检查重复, + // 但两者共用同一个防抖 timer,最终只会扫描一次。 + const shouldRunFullWorkflowCheck = updateData.some( + (item) => + (item.type === 'attr' && item.key === 'catchError') || + ['updateOutput', 'replaceOutput', 'addOutput', 'delOutput'].includes(item.type) + ); + + if (updateData.length > 1 || shouldRunFullWorkflowCheck) { scheduleWorkflowCheckOnEdgeChange(); } else { nodeIdsToRecheck.forEach((nodeId) => scheduleSingleNodeWorkflowCheck(nodeId)); 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 4f777aa21fd8..a62a12730ab3 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -225,7 +225,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({ nodes, edges, - t + t, + chatConfig: appDetail.chatConfig }); if (!hasError) { @@ -280,16 +281,22 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => const nodes = getNodes(); if (nodes.length === 0) return; - const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + const issueMap = checkWorkflowNodeIssues({ + nodes, + edges, + t, + chatConfig: appDetail.chatConfig + }); onSyncWorkflowCheckIssues(issueMap); }; const timer = window.setInterval(runScheduledCheck, 10_000); + runScheduledCheck(); return () => { window.clearInterval(timer); }; - }, [edges, getNodes, onSyncWorkflowCheckIssues, t]); + }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t]); // 4. initData - 初始化工作流数据 const initData = useCallback( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts index 6dbad5dcc58d..4f700b7bd64e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts @@ -1,4 +1,4 @@ -import { getNodeAllSource, workflowReferenceValueIsSelectable } from '@/web/core/workflow/utils'; +import { getNodeAllSource } from '@/web/core/workflow/utils'; import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { @@ -10,11 +10,6 @@ import { type FlowNodeItemType, type StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node'; -import type { - FlowNodeInputItemType, - ReferenceItemValueType, - ReferenceValueType -} from '@fastgpt/global/core/workflow/type/io'; import { getSelectedInputRenderType, nodeInputIsReference @@ -40,22 +35,12 @@ const normalizeStoreNodeInput = (input: StoreNodeItemType['inputs'][number], isT export const uiWorkflow2StoreWorkflow = ({ nodes, - edges, - chatConfig + edges }: { nodes: Node[]; edges: Edge[]; chatConfig?: AppChatConfigType; }) => { - const getNodeById = (nodeId: string | null | undefined) => - nodes.find((node) => node.data.nodeId === nodeId)?.data; - const childrenNodeIdListMap = nodes.reduce>((map, node) => { - const parentNodeId = node.data.parentNodeId; - if (!parentNodeId) return map; - - map[parentNodeId] = [...(map[parentNodeId] ?? []), node.data.nodeId]; - return map; - }, {}); const toolNodeIds = new Set( edges .filter((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools) @@ -95,14 +80,7 @@ export const uiWorkflow2StoreWorkflow = ({ showStatus: item.data.showStatus, position: item.position, version: item.data.version, - inputs: filterUnselectableReferenceInputs({ - node: item.data, - inputs, - edges, - chatConfig, - getNodeById, - childrenNodeIdListMap - }), + inputs, outputs: item.data.outputs, isFolded: item.data.isFolded, pluginId: item.data.pluginId, @@ -134,74 +112,6 @@ export const uiWorkflow2StoreWorkflow = ({ }; }; -const emptyT = ((key: string) => key) as TFunction; - -/** - * 保存时仅持久化当前引用选择器仍能选中的引用项。 - * 已删除来源、已删除输出、类型不再匹配的引用在 UI 上不会展示标签,也不应继续写入 JSON。 - */ -const filterUnselectableReferenceInputs = ({ - node, - inputs, - edges, - chatConfig, - getNodeById, - childrenNodeIdListMap -}: { - node: FlowNodeItemType; - inputs: FlowNodeInputItemType[]; - edges: Edge[]; - chatConfig?: AppChatConfigType; - getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; - childrenNodeIdListMap: Record; -}) => { - return inputs.map((input) => { - if (!nodeInputIsReference(input)) return input; - - const sourceNodes = getNodeAllSource({ - nodeId: node.nodeId, - getNodeById, - edges, - chatConfig: chatConfig ?? ({} as AppChatConfigType), - t: emptyT, - includeChildren: input.canEdit === true, - childrenNodeIdListMap - }); - - const value = input.value as ReferenceValueType | undefined; - if (!Array.isArray(value)) return input; - - if (typeof value[0] === 'string') { - const keepValue = workflowReferenceValueIsSelectable({ - value, - sourceNodes, - valueType: input.valueType - }); - return keepValue - ? input - : { - ...input, - value: undefined - }; - } - - const filteredValue = (value as ReferenceItemValueType[]).filter((item) => - workflowReferenceValueIsSelectable({ - value: item, - sourceNodes, - valueType: input.valueType - }) - ); - - if (filteredValue.length === value.length) return input; - - return { - ...input, - value: filteredValue - }; - }); -}; - export const filterExportModules = (modules: StoreNodeItemType[]) => { modules.forEach((module) => { // dataset - remove select dataset value diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index f592b854fcf8..f9b3371cefcf 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -6,6 +6,7 @@ import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { Edge, Node } from 'reactflow'; import { FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { @@ -23,8 +24,10 @@ import { import type { TFunction } from 'next-i18next'; import type { FlowNodeInputItemType, + FlowNodeOutputItemType, ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io'; +import type { AppChatConfigType } from '@fastgpt/global/core/app/type'; import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; @@ -44,10 +47,11 @@ import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; type WorkflowCheckContext = { nodeMap: Map>; - nodeOutputMap: Map>; + nodeOutputMap: Map>; incomingEdgesMap: Map[]>; outgoingEdgesMap: Map[]>; reachableNodeSet: Set; + chatConfig?: AppChatConfigType; }; const workflowCheckSkipConnectionTypes = new Set([ @@ -211,7 +215,8 @@ const workflowCheckMessageFallback: Record< > = { required_input_empty: ({ inputName } = {}) => `需填写必填项 ${inputName ?? ''}`.trim(), no_upstream: () => '未与其他节点连线', - invalid_reference: ({ inputName } = {}) => `${inputName ?? ''} 引用了无效变量,需删除`.trim(), + invalid_reference: ({ inputName } = {}) => + `${inputName ?? ''} 中存在已被删除的变量,需处理`.trim(), if_else_incomplete: () => '存在未完成的条件配置,请完善', user_select_empty: () => '需配置至少一个选项', user_select_value_empty: () => '选项不可为空', @@ -326,19 +331,24 @@ export const getWorkflowCheckIssueMessage = ( const createWorkflowCheckContext = ({ nodes, - edges + edges, + chatConfig }: { nodes: Node[]; edges: Edge[]; + chatConfig?: AppChatConfigType; }): WorkflowCheckContext => { const nodeMap = new Map>(); - const nodeOutputMap = 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))); + nodeOutputMap.set( + node.data.nodeId, + new Map(node.data.outputs.map((output) => [output.id, output])) + ); incomingEdgesMap.set(node.data.nodeId, []); outgoingEdgesMap.set(node.data.nodeId, []); }); @@ -377,7 +387,8 @@ const createWorkflowCheckContext = ({ nodeOutputMap, incomingEdgesMap, outgoingEdgesMap, - reachableNodeSet + reachableNodeSet, + chatConfig }; }; @@ -387,23 +398,38 @@ const referenceValueIsLive = ( ) => { if (!isValidReferenceValueFormat(value)) return false; const [refNodeId, refOutputId] = value; + if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return false; if (!refNodeId || !refOutputId) return false; - if (refNodeId === VARIABLE_NODE_ID) return true; + if (refNodeId === VARIABLE_NODE_ID) { + return context.chatConfig === undefined + ? true + : context.chatConfig.variables?.some((variable) => variable.key === refOutputId) === true; + } + + const output = context.nodeOutputMap.get(refNodeId)?.get(refOutputId); + if (!output || output.invalid === true || output.id === NodeOutputKeyEnum.addOutputParam) { + return false; + } - return context.nodeOutputMap.get(refNodeId)?.has(refOutputId) === true; + return ( + output.type !== FlowNodeOutputTypeEnum.error || + context.nodeMap.get(refNodeId)?.data.catchError === true + ); }; -/** 引用输入是否尚未选择(空占位 / 未选变量),区别于曾经选中但已失效的引用。 */ +/** 仅把空值和两个历史空占位视为未选择,其余非空 malformed value 都应报告 invalid_reference。 */ const isUnsetReferenceValue = (value: unknown) => { if (value === undefined || value === null || value === '') return true; - if (!Array.isArray(value)) return true; + if (!Array.isArray(value)) return false; if (value.length === 0) return true; - // 单引用 [nodeId, outputId];占位符 ['', ''] 或格式不完整均视为未选择 + // 保留历史数据中的 ['', ''] 和 [undefined, undefined] 空占位兼容行为。 if (value.length === 2 && !Array.isArray(value[0])) { const [refNodeId, refOutputId] = value; - if (typeof refNodeId !== 'string') return true; - return !refNodeId || !refOutputId; + return ( + (refNodeId === '' && refOutputId === '') || + (refNodeId === undefined && refOutputId === undefined) + ); } return false; @@ -416,6 +442,27 @@ const isEmptyReferenceInputValue = (value: unknown, isArrayType: boolean) => { return isUnsetReferenceValue(value); }; +/** + * 判断引用值是否「已配置」:空值和 ['', ''] / [undefined, undefined] 两个历史空占位视为未选择, + * 其余非空值(含格式错误的值)一律视为已配置。 + * 运行检查与引用选择器 UI 共用,避免空占位判定标准两处维护产生漂移。 + */ +export const isConfiguredReferenceValue = (value: unknown) => !isUnsetReferenceValue(value); + +const getReferenceItems = (value: unknown): ReferenceItemValueType[] => { + if (Array.isArray(value) && typeof value[0] === 'string') { + return [value as ReferenceItemValueType]; + } + + return Array.isArray(value) + ? (value as ReferenceItemValueType[]) + : [value as ReferenceItemValueType]; +}; + +const referenceValueHasInvalidSource = (value: unknown, context: WorkflowCheckContext) => + isConfiguredReferenceValue(value) && + getReferenceItems(value).some((item) => !referenceValueIsLive(item, context)); + const isVariableUpdateTargetEmpty = ( variable: unknown, nodeIds: string[], @@ -442,6 +489,10 @@ const isVariableUpdateValueEmpty = (item: TUpdateListItem, context: WorkflowChec return inputVal === undefined || inputVal === null || inputVal === ''; }; +const isVariableUpdateValueInvalid = (item: TUpdateListItem, context: WorkflowCheckContext) => + item.renderType === FlowNodeInputTypeEnum.reference && + referenceValueHasInvalidSource(item.value, context); + /** * 结构化校验工作流节点和连线。 * 函数只读取入参并返回每个节点的错误列表,调用方负责写入 React state、toast 或定位画布。 @@ -450,14 +501,16 @@ export const checkWorkflowNodeIssues = ({ nodes, edges, nodeId, - t + t, + chatConfig }: { nodes: Node[]; edges: Edge[]; nodeId?: string; t?: TFunction; + chatConfig?: AppChatConfigType; }): WorkflowCheckNodeIssueMap => { - const context = createWorkflowCheckContext({ nodes, edges }); + const context = createWorkflowCheckContext({ nodes, edges, chatConfig }); const issueMap: WorkflowCheckNodeIssueMap = {}; const nodeIds = nodes.map((node) => node.data.nodeId); const targetNodes = nodeId ? nodes.filter((node) => node.data.nodeId === nodeId) : nodes; @@ -713,7 +766,15 @@ export const checkWorkflowNodeIssues = ({ | TUpdateListItem[] | undefined; - const addVariableUpdateRequiredIssue = (field: 'variable' | 'value') => { + const addVariableUpdateIssue = ({ + field, + index, + code + }: { + field: 'variable' | 'value'; + index?: number; + code: 'required_input_empty' | 'invalid_reference'; + }) => { const inputName = (() => { if (field === 'variable') { return t ? t('common:core.workflow.variable' as any) : '变量'; @@ -724,24 +785,39 @@ export const checkWorkflowNodeIssues = ({ addIssue({ node, - code: 'required_input_empty', - message: getWorkflowCheckIssueMessage('required_input_empty', t, { + code, + message: getWorkflowCheckIssueMessage(code, t, { inputName }), - inputKey: NodeInputKeyEnum.updateList + inputKey: + code === 'invalid_reference' && index !== undefined + ? `${NodeInputKeyEnum.updateList}[${index}].${field}` + : NodeInputKeyEnum.updateList }); }; if (!updateList || updateList.length === 0) { - addVariableUpdateRequiredIssue('variable'); - addVariableUpdateRequiredIssue('value'); + addVariableUpdateIssue({ field: 'variable', code: 'required_input_empty' }); + addVariableUpdateIssue({ field: 'value', code: 'required_input_empty' }); } else { - updateList.forEach((item) => { + updateList.forEach((item, index) => { if (isVariableUpdateTargetEmpty(item.variable, nodeIds, context)) { - addVariableUpdateRequiredIssue('variable'); + addVariableUpdateIssue({ + field: 'variable', + index, + code: isConfiguredReferenceValue(item.variable) + ? 'invalid_reference' + : 'required_input_empty' + }); } if (isVariableUpdateValueEmpty(item, context)) { - addVariableUpdateRequiredIssue('value'); + addVariableUpdateIssue({ + field: 'value', + index, + code: isVariableUpdateValueInvalid(item, context) + ? 'invalid_reference' + : 'required_input_empty' + }); } }); } @@ -760,6 +836,23 @@ export const checkWorkflowNodeIssues = ({ } } + const isReferenceInput = nodeInputIsReference(input); + const isArrayReference = isReferenceInput && !!input.valueType?.startsWith('array'); + // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 + const effectiveInputValue = input.value ?? input.defaultValue; + + // 已配置的引用始终检查,即使目标输入没有 valueType 或 required=false。 + if (isReferenceInput && referenceValueHasInvalidSource(effectiveInputValue, context)) { + addIssue({ + node, + code: 'invalid_reference', + message: getWorkflowCheckIssueMessage('invalid_reference', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); + } + if (shouldSkipGenericRequiredInputCheck(input)) { return; } @@ -779,10 +872,6 @@ export const checkWorkflowNodeIssues = ({ return; } - const isReferenceInput = nodeInputIsReference(input); - const isArrayReference = isReferenceInput && !!input.valueType?.startsWith('array'); - // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 - const effectiveInputValue = input.value ?? input.defaultValue; const inputValueIsEmpty = isReferenceInput ? isEmptyReferenceInputValue(effectiveInputValue, isArrayReference) : isEmptyWorkflowInputValue(effectiveInputValue); @@ -864,13 +953,15 @@ export const getWorkflowCheckErrorNodeIds = ( export const checkWorkflowBeforeRunOrPublish = ({ nodes, edges, - t + t, + chatConfig }: { nodes: Node[]; edges: Edge[]; t?: TFunction; + chatConfig?: AppChatConfigType; }) => { - const issueMap = checkWorkflowNodeIssues({ nodes, edges, t }); + const issueMap = checkWorkflowNodeIssues({ nodes, edges, t, chatConfig }); const nodeOrder = nodes.map((node) => node.data.nodeId); const errorNodeIds = getWorkflowCheckErrorNodeIds(issueMap, nodeOrder); diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index 274d9db4de57..fbbb919dc8a0 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -332,7 +332,7 @@ describe('WorkflowComponents utils', () => { expect(result.nodes[1].inputs[0].value).toEqual(referenceValue); }); - it('should filter only unselectable values from reference inputs', () => { + it('should preserve unselectable values from reference inputs', () => { const nodes = [ { data: { @@ -431,11 +431,14 @@ describe('WorkflowComponents utils', () => { 'sourceNode', 'text' ]); - expect( - storedInputs.find((input) => input.key === 'invalidSingleReference')?.value - ).toBeUndefined(); + expect(storedInputs.find((input) => input.key === 'invalidSingleReference')?.value).toEqual([ + 'missingNode', + 'text' + ]); expect(storedInputs.find((input) => input.key === 'multipleReferences')?.value).toEqual([ - ['sourceNode', 'files'] + ['sourceNode', 'files'], + ['sourceNode', 'count'], + ['missingNode', 'files'] ]); expect(storedInputs.find((input) => input.key === 'textareaValue')?.value).toBe( '{{missingNode.text}}' 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 ae5ea52aaa7b..5195b569522e 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -207,7 +207,7 @@ describe('checkWorkflowNodeIssues', () => { expect(result.orphan.map((issue) => issue.code)).toContain('no_upstream'); }); - it('does not report unselectable references', () => { + it('reports invalid references', () => { const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { inputs: [ { @@ -225,10 +225,157 @@ describe('checkWorkflowNodeIssues', () => { edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] }); - expect(result.ref?.map((issue) => issue.code) ?? []).not.toContain('invalid_reference'); + expect(result.ref?.map((issue) => issue.code) ?? []).toContain('invalid_reference'); }); - it('filters unselectable single and multiple references when storing workflow data', () => { + it('reports deleted global variables and unavailable outputs as invalid references', () => { + const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, { + catchError: false, + outputs: [ + { + id: 'invalid-output', + key: 'invalid-output', + label: 'invalid output', + type: FlowNodeOutputTypeEnum.static, + invalid: true + }, + { + id: NodeOutputKeyEnum.addOutputParam, + key: NodeOutputKeyEnum.addOutputParam, + label: 'dynamic output placeholder', + type: FlowNodeOutputTypeEnum.dynamic + }, + { + id: 'error-output', + key: 'error-output', + label: 'error output', + type: FlowNodeOutputTypeEnum.error + } + ] + }); + const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: 'deleted-variable', + label: 'deleted variable', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: [VARIABLE_NODE_ID, 'deleted-variable'] + }, + { + key: 'invalid-output', + label: 'invalid output', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['source', 'invalid-output'] + }, + { + key: 'dynamic-output', + label: 'dynamic output', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['source', NodeOutputKeyEnum.addOutputParam] + }, + { + key: 'malformed-reference', + label: 'malformed reference', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['source'] + }, + { + key: 'error-output', + label: 'error output', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['source', 'error-output'] + }, + { + key: 'valid-variable', + label: 'valid variable', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: [VARIABLE_NODE_ID, 'kept-variable'] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [sourceNode, node], + edges: [{ id: 'e1', source: 'source', target: 'ref', type: EDGE_TYPE }], + chatConfig: { + variables: [{ key: 'kept-variable' }] + } as any + }); + + const invalidIssues = result.ref?.filter((issue) => issue.code === 'invalid_reference') ?? []; + expect(invalidIssues.map((issue) => issue.inputKey)).toEqual( + expect.arrayContaining([ + 'deleted-variable', + 'invalid-output', + 'dynamic-output', + 'malformed-reference', + 'error-output' + ]) + ); + expect(invalidIssues.map((issue) => issue.inputKey)).not.toContain('valid-variable'); + }); + + it('reports a global reference as invalid when the current chat config has no variables', () => { + const node = makeNode('missing-global', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: NodeInputKeyEnum.answerText, + label: 'answer', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: [VARIABLE_NODE_ID, 'missing-variable'] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [ + { id: 'e-start-missing-global', source: 'start', target: 'missing-global', type: EDGE_TYPE } + ], + chatConfig: {} + }); + + expect(result['missing-global']?.map((issue) => issue.code)).toContain('invalid_reference'); + }); + + it('locates invalid VariableUpdate targets and values by row field', () => { + const node = makeNode('variable-update', FlowNodeTypeEnum.variableUpdate, { + inputs: [ + { + key: NodeInputKeyEnum.updateList, + label: 'update list', + value: [ + { + variable: ['deleted-node', 'output'], + value: [['deleted-node', 'output']], + renderType: FlowNodeInputTypeEnum.reference, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node], + edges: [{ id: 'e1', source: 'start', target: 'variable-update', type: EDGE_TYPE }] + }); + + expect(result['variable-update']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_reference', + inputKey: `${NodeInputKeyEnum.updateList}[0].variable` + }), + expect.objectContaining({ + code: 'invalid_reference', + inputKey: `${NodeInputKeyEnum.updateList}[0].value` + }) + ]) + ); + }); + + it('preserves invalid single and multiple references when storing workflow data', () => { const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, { outputs: [ { @@ -286,14 +433,14 @@ describe('checkWorkflowNodeIssues', () => { expect( storedNode?.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput) - ).toEqual( - expect.objectContaining({ - value: undefined - }) - ); + ).toEqual(expect.objectContaining({ value: ['source', 'count'] })); expect(storedNode?.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)).toEqual( expect.objectContaining({ - value: [['source', 'files']] + value: [ + ['source', 'files'], + ['source', 'deleted'], + ['source', 'count'] + ] }) ); }); @@ -841,7 +988,7 @@ describe('checkWorkflowNodeIssues', () => { }); }); - it('does not return invalid reference message for unselectable references', () => { + it('returns invalid reference message for unselectable references', () => { const node = makeNode('ref', FlowNodeTypeEnum.answerNode, { inputs: [ { @@ -859,7 +1006,7 @@ describe('checkWorkflowNodeIssues', () => { edges: [{ id: 'e1', source: 'start', target: 'ref', type: EDGE_TYPE }] }); - expect(result.ref?.map((issue) => issue.code) ?? []).not.toContain('invalid_reference'); + expect(result.ref?.map((issue) => issue.code) ?? []).toContain('invalid_reference'); }); it('treats unset reference as required_input_empty instead of invalid_reference', () => { @@ -1454,7 +1601,7 @@ describe('checkWorkflowNodeIssues', () => { result[nodeId]?.filter((issue) => issue.inputKey === inputKey).map((issue) => issue.code) ?? []; - it('does not report imported tool call file link auto-fill when current workflow start has no userFiles output', () => { + it('reports imported tool call file link auto-fill when current workflow start has no userFiles output', () => { const importedToolCall = makeImportedConnectedNode({ nodeId: 'tool-call', template: ToolCallNode, @@ -1483,7 +1630,7 @@ describe('checkWorkflowNodeIssues', () => { expect(getInput(importedToolCall, NodeInputKeyEnum.fileUrlList).value).toEqual([ ['start', NodeOutputKeyEnum.userFiles] ]); - expect(getIssueCodes('tool-call', NodeInputKeyEnum.fileUrlList)(result)).not.toContain( + expect(getIssueCodes('tool-call', NodeInputKeyEnum.fileUrlList)(result)).toContain( 'invalid_reference' ); }); @@ -1523,7 +1670,7 @@ describe('checkWorkflowNodeIssues', () => { ]); } - expect(getIssueCodes(nodeId, inputKey)(result)).not.toContain('invalid_reference'); + expect(getIssueCodes(nodeId, inputKey)(result)).toContain('invalid_reference'); } ); @@ -1620,9 +1767,9 @@ describe('checkWorkflowNodeIssues', () => { ] }); - expect( - getIssueCodes('invalid-tool-call', NodeInputKeyEnum.userChatInput)(result) - ).not.toContain('invalid_reference'); + expect(getIssueCodes('invalid-tool-call', NodeInputKeyEnum.userChatInput)(result)).toContain( + 'invalid_reference' + ); }); it('does not report invalid references mixed into an imported file input', () => { @@ -1652,11 +1799,11 @@ describe('checkWorkflowNodeIssues', () => { expect( getIssueCodes('mixed-invalid-tool-call', NodeInputKeyEnum.fileUrlList)(result) - ).not.toContain('invalid_reference'); + ).toContain('invalid_reference'); }); }); - it('does not report invalid_reference when referenced upstream node or output was deleted', () => { + it('reports invalid_reference when referenced upstream node or output was deleted', () => { const nodeWithDeletedNodeRef = makeNode('deleted-node', FlowNodeTypeEnum.chatNode, { inputs: [ { @@ -1692,13 +1839,11 @@ describe('checkWorkflowNodeIssues', () => { ] }); - expect(result['deleted-node']?.map((issue) => issue.code) ?? []).not.toContain( - 'invalid_reference' - ); + 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) ?? []).not.toContain( + expect(result['deleted-output']?.map((issue) => issue.code) ?? []).toContain( 'invalid_reference' ); expect(result['deleted-output']?.map((issue) => issue.code) ?? []).not.toContain( From dd4a5aab0077dc5502baa43c9d0cac65b38f620c Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 26 Aug 2026 18:20:58 +0800 Subject: [PATCH 02/18] feat(workflow): invalid reference type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共享类型兼容谓词判定引用类型不兼容,选择器按列表自行区分删除/类型失效原因,新增类型问题聚合 Toast。 --- packages/web/i18n/en/common.json | 3 + packages/web/i18n/ko-KR/common.json | 3 + packages/web/i18n/zh-CN/common.json | 3 + packages/web/i18n/zh-Hant/common.json | 3 + .../NodeVariableUpdate/VariableSelector.tsx | 3 +- .../Flow/nodes/NodeVariableUpdate/index.tsx | 18 +- .../RenderInput/templates/Reference.tsx | 150 ++++++--- .../context/workflowActionsContext.tsx | 21 +- .../context/workflowUtilsContext.tsx | 27 +- projects/app/src/web/core/workflow/utils.ts | 137 ++++---- .../src/web/core/workflow/workflowCheck.ts | 129 +++++++- .../test/web/core/app/workflow/utils.test.ts | 295 ++++++++++++++++++ 12 files changed, 664 insertions(+), 128 deletions(-) diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index ff6f205884a8..fc6a13fb0784 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -652,8 +652,11 @@ "core.workflow.check.http_url_empty": "Configure request URL", "core.workflow.check.if_else_incomplete": "Incomplete condition configuration, please complete", "core.workflow.check.invalid_reference": "{{inputName}} contains a deleted Variable. Action required.", + "core.workflow.check.invalid_reference_type": "{{inputName}} references a variable with a mismatched type. Action required.", + "core.workflow.check.invalid_reference_type_toast": "A referenced variable has a mismatched type. Check the affected nodes.", "core.workflow.check.no_upstream": "Not connected to other nodes", "core.workflow.check.reference_deleted": "Variable deleted", + "core.workflow.check.reference_type_mismatch": "Variable type mismatch", "core.workflow.check.required_input_empty": "Please fill in required field {{inputName}}", "core.workflow.check.status.pending_handle": "To handle", "core.workflow.check.status.pending_improve": "To complete", diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index 6ca77e4ad572..d73c8ecbef5e 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -650,8 +650,11 @@ "core.workflow.check.http_url_empty": "요청 URL을 설정하세요", "core.workflow.check.if_else_incomplete": "완료되지 않은 조건 설정이 있습니다. 설정을 완료하세요", "core.workflow.check.invalid_reference": "{{inputName}}에 삭제된 변수가 있습니다. 처리가 필요합니다.", + "core.workflow.check.invalid_reference_type": "{{inputName}}에 유형이 일치하지 않는 변수 참조가 있습니다. 처리가 필요합니다.", + "core.workflow.check.invalid_reference_type_toast": "유형이 일치하지 않는 변수 참조가 감지되었습니다. 관련 노드를 확인하세요.", "core.workflow.check.no_upstream": "다른 노드와 연결되지 않았습니다", "core.workflow.check.reference_deleted": "변수 삭제됨", + "core.workflow.check.reference_type_mismatch": "변수 유형 불일치", "core.workflow.check.required_input_empty": "필수 항목 {{inputName}}을(를) 입력하세요", "core.workflow.check.status.pending_handle": "처리 대기", "core.workflow.check.status.pending_improve": "보완 필요", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 75ae4e99d9cd..e2374787a603 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -652,8 +652,11 @@ "core.workflow.check.http_url_empty": "需配置请求地址", "core.workflow.check.if_else_incomplete": "存在未完成的条件配置,请完善", "core.workflow.check.invalid_reference": "{{inputName}} 中存在已被删除的变量,需处理", + "core.workflow.check.invalid_reference_type": "{{inputName}} 中引用的变量类型不匹配,需处理", + "core.workflow.check.invalid_reference_type_toast": "检测到引用类型不匹配的变量,请检查相关节点", "core.workflow.check.no_upstream": "未与其他节点连线", "core.workflow.check.reference_deleted": "变量已删除", + "core.workflow.check.reference_type_mismatch": "变量类型不匹配", "core.workflow.check.required_input_empty": "需填写必填项 {{inputName}}", "core.workflow.check.status.pending_handle": "待处理", "core.workflow.check.status.pending_improve": "待完善", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 0e0281c502a6..5ec88bcfeb20 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -652,8 +652,11 @@ "core.workflow.check.http_url_empty": "需配置請求地址", "core.workflow.check.if_else_incomplete": "存在未完成的條件配置,請完善", "core.workflow.check.invalid_reference": "{{inputName}} 中存在已被刪除的變量,需處理", + "core.workflow.check.invalid_reference_type": "{{inputName}} 中引用的變量類型不匹配,需處理", + "core.workflow.check.invalid_reference_type_toast": "偵測到引用類型不匹配的變量,請檢查相關節點", "core.workflow.check.no_upstream": "未與其他節點連線", "core.workflow.check.reference_deleted": "變量已刪除", + "core.workflow.check.reference_type_mismatch": "變量類型不匹配", "core.workflow.check.required_input_empty": "需填寫必填項 {{inputName}}", "core.workflow.check.status.pending_handle": "待處理", "core.workflow.check.status.pending_improve": "待完善", diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx index 5adb3a687b9b..42263d37ce9e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx @@ -14,7 +14,7 @@ type Props = { const VariableSelector = ({ nodeId, variable, valueType, onSelect }: Props) => { const { t } = useTranslation(); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType }); @@ -23,6 +23,7 @@ const VariableSelector = ({ nodeId, variable, valueType, onSelect }: Props) => { ) => const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { edges, getNodeById } = useContextSelector( - WorkflowBufferDataContext, - (v) => v - ); + const { edges, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); const appDetail = useContextSelector(AppContext, (v) => v.appDetail); const variables = useMemoEnhance(() => { @@ -112,6 +109,14 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const ValueRow = useMemoizedFn( ({ updateItem, index }: { updateItem: TUpdateListItem; index: number }) => { + // 目标变量缓存类型过期时,变量仍在可选列表中,选择器自身无法感知, + // 由行级直接消费检查结论展示提示;值引用的类型问题选择器可按列表自行判断 + const hasTargetTypeIssue = data.workflowCheckIssues?.some( + (issue) => + issue.code === 'invalid_reference_type' && + issue.inputKey === `${NodeInputKeyEnum.updateList}[${index}].variable` + ); + // 根据目标变量推断 string 分支的 inputType 与表单参数(select options 等) const { inputType, formParams = {} } = (() => { const value = updateItem.variable; @@ -198,6 +203,11 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => + {hasTargetTypeIssue && ( + + {t('common:core.workflow.check.reference_type_mismatch')} + + )} {/* 值 */} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index a0150830b4e6..cb188124f7be 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -11,6 +11,7 @@ import type { ReferenceItemValueType, ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; import dynamic from 'next/dynamic'; import { useContextSelector } from 'use-context-selector'; import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant'; @@ -31,17 +32,21 @@ const MultipleRowArraySelect = dynamic(() => ); const Avatar = dynamic(() => import('@fastgpt/web/components/common/Avatar')); -type CommonSelectProps = { - placeholder?: string; - list: { - label: string | React.ReactNode; +type ReferenceSelectList = { + label: string | React.ReactNode; + value: string; + children: { + label: string; value: string; - children: { - label: string; - value: string; - valueType?: WorkflowIOValueTypeEnum; - }[]; + valueType?: WorkflowIOValueTypeEnum; }[]; +}[]; + +type CommonSelectProps = { + placeholder?: string; + list: ReferenceSelectList; + // 不做类型过滤的可选输出列表,仅用于区分「来源已删除」和「类型不匹配」两种失效原因 + liveList?: ReferenceSelectList; popDirection?: 'top' | 'bottom'; ButtonProps?: ButtonProps; }; @@ -70,7 +75,7 @@ export const useReference = ({ ); // 获取可选的变量列表 - const referenceList = useMemoEnhance(() => { + const { referenceList, liveReferenceList } = useMemoEnhance(() => { const sourceNodes = getNodeAllSource({ nodeId, getNodeById, @@ -83,33 +88,53 @@ export const useReference = ({ const isArray = valueType?.includes('array'); - // 转换为 select 的数据结构 - const list: CommonSelectProps['list'] = sourceNodes - .map((node) => { - return { - label: ( - - - {node.name} - - ), + const referenceList: ReferenceSelectList = []; + // 失效原因分类专用:类型不受限时的可选输出;来源仍在这里但不在 referenceList 中即为类型不匹配 + const liveReferenceList: ReferenceSelectList = []; + + sourceNodes.forEach((node) => { + const label = ( + + + {node.name} + + ); + const toChildren = (outputs: FlowNodeOutputItemType[]) => + outputs.map((output) => ({ + label: t(output.label as any), + value: output.id, + valueType: output.valueType + })); + + // 转换为 select 的数据结构 + const selectableOutputs = filterSelectableWorkflowNodeOutputs({ + outputs: node.outputs, + valueType, + catchError: node.catchError + }); + if (selectableOutputs.length > 0) { + referenceList.push({ + label, + value: node.nodeId, + children: toChildren(selectableOutputs) + }); + } + + const liveOutputs = filterSelectableWorkflowNodeOutputs({ + outputs: node.outputs, + valueType: WorkflowIOValueTypeEnum.any, + catchError: node.catchError + }); + if (liveOutputs.length > 0) { + liveReferenceList.push({ + label, value: node.nodeId, - children: filterSelectableWorkflowNodeOutputs({ - outputs: node.outputs, - valueType, - catchError: node.catchError - }).map((output) => { - return { - label: t(output.label as any), - value: output.id, - valueType: output.valueType - }; - }) - }; - }) - .filter((item) => item.children.length > 0); - - return list; + children: toChildren(liveOutputs) + }); + } + }); + + return { referenceList, liveReferenceList }; }, [ nodeId, getNodeById, @@ -122,7 +147,8 @@ export const useReference = ({ ]); return { - referenceList + referenceList, + liveReferenceList }; }; @@ -149,7 +175,7 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { [item, nodeId, onChangeNode] ); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: item.valueType }); @@ -164,6 +190,7 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { { + (value: ReferenceValueType, searchList: ReferenceSelectList) => { if (!value) return []; - const firstColumn = list.find((item) => item.value === value[0]); + const firstColumn = searchList.find((item) => item.value === value[0]); if (!firstColumn) { return []; } @@ -198,7 +226,7 @@ const SingleReferenceSelector = ({ } return [firstColumn.label, secondColumn.label]; }, - [list] + [] ); // Adapt array type from old version @@ -217,9 +245,14 @@ const SingleReferenceSelector = ({ const ItemSelector = useMemo(() => { const selectorVal = value as ReferenceItemValueType; - const [nodeName, outputName] = getSelectValue(selectorVal); - const isValidSelect = nodeName && outputName; + const [nodeName, outputName] = getSelectValue(selectorVal, list); + const isValidSelect = Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; + // 来源仍在无类型过滤列表中即为类型不匹配,否则按来源已删除提示 + const [liveNodeName, liveOutputName] = isInvalidReference + ? getSelectValue(selectorVal, liveList) + : []; + const isTypeMismatch = isInvalidReference && Boolean(liveNodeName && liveOutputName); return ( - {isInvalidReference ? t('common:core.workflow.check.reference_deleted') : placeholder} + {isInvalidReference + ? isTypeMismatch + ? t('common:core.workflow.check.reference_type_mismatch') + : t('common:core.workflow.check.reference_deleted') + : placeholder} ) } @@ -247,7 +284,7 @@ const SingleReferenceSelector = ({ ButtonProps={ButtonProps} /> ); - }, [ButtonProps, getSelectValue, list, onSelect, placeholder, popDirection, t, value]); + }, [ButtonProps, getSelectValue, list, liveList, onSelect, placeholder, popDirection, t, value]); return ItemSelector; }; @@ -255,16 +292,17 @@ const MultipleReferenceSelector = ({ placeholder, value, list = [], + liveList = [], onSelect, popDirection }: SelectProps) => { const { t } = useSafeTranslation(); const getSelectValue = useCallback( - (value: ReferenceValueType) => { + (value: ReferenceValueType, searchList: ReferenceSelectList) => { if (!value) return []; - const firstColumn = list.find((item) => item.value === value[0]); + const firstColumn = searchList.find((item) => item.value === value[0]); if (!firstColumn) { return []; } @@ -274,7 +312,7 @@ const MultipleReferenceSelector = ({ } return [firstColumn.label, secondColumn.label]; }, - [list] + [] ); // Get valid item and remove invalid item @@ -282,14 +320,14 @@ const MultipleReferenceSelector = ({ if (!value || !Array.isArray(value)) return []; return value.map((item) => { - const [nodeName, outputName] = getSelectValue(item); + const [nodeName, outputName] = getSelectValue(item, list); return { rawValue: item, nodeName, outputName }; }); - }, [getSelectValue, value]); + }, [getSelectValue, list, value]); useEffect(() => { // Adapt array type from old version @@ -319,6 +357,12 @@ const MultipleReferenceSelector = ({ const isValidReference = Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(rawValue) && !isValidReference; + // 多选失效项区分原因:来源仍在(无类型过滤列表可命中)为类型不匹配,否则为已删除 + const [liveNodeName, liveOutputName] = isInvalidReference + ? getSelectValue(rawValue, liveList) + : []; + const isTypeMismatch = + isInvalidReference && Boolean(liveNodeName && liveOutputName); return ( ) : isInvalidReference ? ( - {t('common:core.workflow.check.reference_deleted')} + {isTypeMismatch + ? t('common:core.workflow.check.reference_type_mismatch') + : t('common:core.workflow.check.reference_deleted')} ) : ( {placeholder} @@ -384,7 +430,7 @@ const MultipleReferenceSelector = ({ popDirection={popDirection} /> ); - }, [formatList, list, onSelect, placeholder, popDirection, t, value]); + }, [formatList, getSelectValue, list, liveList, onSelect, placeholder, popDirection, t, value]); return ArraySelector; }; 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 0996a7711419..0e561b0d9457 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -15,7 +15,10 @@ import type { WorkflowCheckNodeIssueMap } from '@fastgpt/global/core/workflow/type/node'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { checkWorkflowNodeIssues } from '@/web/core/workflow/workflowCheck'; +import { + checkWorkflowNodeIssues, + countNewWorkflowCheckIssues +} from '@/web/core/workflow/workflowCheck'; import { collectWorkflowStartAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import { AppContext } from '@/pageComponents/app/detail/context'; @@ -308,9 +311,23 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod t, chatConfig: appDetail.chatConfig }); + + // 输出/catchError 变化产生新增类型不兼容时聚合提示一次,已有问题不重复提醒 + const newTypeIssueCount = countNewWorkflowCheckIssues({ + issueMap, + prevNodes: nodes, + code: 'invalid_reference_type' + }); + if (newTypeIssueCount > 0) { + toast({ + status: 'warning', + title: t('common:core.workflow.check.invalid_reference_type_toast') + }); + } + onSyncWorkflowCheckIssues(issueMap); }, 400); - }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t]); + }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); useEffect(() => { if (isFirstEdgesEffectRef.current) { 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 a62a12730ab3..a8bd8a8c4b99 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -1,5 +1,5 @@ // 工作流工具函数层 -import React, { type ReactNode, useCallback, useEffect, useMemo } from 'react'; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; import { createContext, useContextSelector } from 'use-context-selector'; import { useReactFlow } from 'reactflow'; import { useTranslation } from 'next-i18next'; @@ -7,7 +7,8 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; import { storeNode2FlowNode, storeEdge2RenderEdge } from '@/web/core/workflow/utils'; import { checkWorkflowBeforeRunOrPublish, - checkWorkflowNodeIssues + checkWorkflowNodeIssues, + countNewWorkflowCheckIssues } from '@/web/core/workflow/workflowCheck'; import { uiWorkflow2StoreWorkflow } from '../utils'; import { @@ -123,6 +124,9 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => const { t } = useTranslation(); const { toast } = useToast(); const { fitView } = useReactFlow(); + + // 首次进入编辑页的全量扫描不对存量类型问题提示;之后的检查才聚合提示新增类型问题 + const hasRunScheduledCheckRef = useRef(false); const { feConfigs } = useSystemStore(); const { teamPlanStatus } = useUserStore(); const showSandbox = feConfigs?.show_agent_sandbox; @@ -287,6 +291,23 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => t, chatConfig: appDetail.chatConfig }); + + // 变量配置变化会立即重跑本检查;新增类型不兼容时聚合提示一次 + if (hasRunScheduledCheckRef.current) { + const newTypeIssueCount = countNewWorkflowCheckIssues({ + issueMap, + prevNodes: nodes, + code: 'invalid_reference_type' + }); + if (newTypeIssueCount > 0) { + toast({ + status: 'warning', + title: t('common:core.workflow.check.invalid_reference_type_toast') + }); + } + } + hasRunScheduledCheckRef.current = true; + onSyncWorkflowCheckIssues(issueMap); }; @@ -296,7 +317,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => return () => { window.clearInterval(timer); }; - }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t]); + }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); // 4. initData - 初始化工作流数据 const initData = useCallback( diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 3dfd6dc6df6b..25d58bd1a6e9 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -337,74 +337,83 @@ export const getRefData = ({ required: !!output.required }; }; +// 来源类型可赋值给目标类型的规则表,引用选择器过滤与工作流检查共用同一份。 +const workflowValueTypeCompatMap: Record = { + [WorkflowIOValueTypeEnum.string]: [WorkflowIOValueTypeEnum.string], + [WorkflowIOValueTypeEnum.number]: [WorkflowIOValueTypeEnum.number], + [WorkflowIOValueTypeEnum.boolean]: [WorkflowIOValueTypeEnum.boolean], + [WorkflowIOValueTypeEnum.object]: [WorkflowIOValueTypeEnum.object], + [WorkflowIOValueTypeEnum.arrayString]: [ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayNumber]: [ + WorkflowIOValueTypeEnum.number, + WorkflowIOValueTypeEnum.arrayNumber, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayBoolean]: [ + WorkflowIOValueTypeEnum.boolean, + WorkflowIOValueTypeEnum.arrayBoolean, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayObject]: [ + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayObject, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.chatHistory, + WorkflowIOValueTypeEnum.datasetQuote, + WorkflowIOValueTypeEnum.dynamic, + WorkflowIOValueTypeEnum.selectDataset, + WorkflowIOValueTypeEnum.selectApp + ], + [WorkflowIOValueTypeEnum.chatHistory]: [ + WorkflowIOValueTypeEnum.chatHistory, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.datasetQuote]: [ + WorkflowIOValueTypeEnum.datasetQuote, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.dynamic]: [ + WorkflowIOValueTypeEnum.dynamic, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.selectDataset]: [ + WorkflowIOValueTypeEnum.selectDataset, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.selectApp]: [ + WorkflowIOValueTypeEnum.selectApp, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayAny]: [WorkflowIOValueTypeEnum.arrayAny], + [WorkflowIOValueTypeEnum.any]: [WorkflowIOValueTypeEnum.arrayAny] +}; + +/** + * 判断来源类型能否赋值给目标类型。 + * 目标为 any/arrayAny 或未声明、来源无类型或为 any 时一律兼容, + * 与 ReferenceSelector 的可选过滤行为保持一致。 + */ +export const workflowValueTypeIsCompatible = ( + sourceType: WorkflowIOValueTypeEnum | undefined, + targetType: WorkflowIOValueTypeEnum | undefined +): boolean => + !targetType || + targetType === WorkflowIOValueTypeEnum.any || + targetType === WorkflowIOValueTypeEnum.arrayAny || + !sourceType || + sourceType === WorkflowIOValueTypeEnum.any || + workflowValueTypeCompatMap[targetType]?.includes(sourceType) === true; + // 根据数据类型,过滤无效的节点输出 export const filterWorkflowNodeOutputsByType = ( outputs: FlowNodeOutputItemType[], valueType: WorkflowIOValueTypeEnum -): FlowNodeOutputItemType[] => { - const validTypeMap: Record = { - [WorkflowIOValueTypeEnum.string]: [WorkflowIOValueTypeEnum.string], - [WorkflowIOValueTypeEnum.number]: [WorkflowIOValueTypeEnum.number], - [WorkflowIOValueTypeEnum.boolean]: [WorkflowIOValueTypeEnum.boolean], - [WorkflowIOValueTypeEnum.object]: [WorkflowIOValueTypeEnum.object], - [WorkflowIOValueTypeEnum.arrayString]: [ - WorkflowIOValueTypeEnum.string, - WorkflowIOValueTypeEnum.arrayString, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.arrayNumber]: [ - WorkflowIOValueTypeEnum.number, - WorkflowIOValueTypeEnum.arrayNumber, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.arrayBoolean]: [ - WorkflowIOValueTypeEnum.boolean, - WorkflowIOValueTypeEnum.arrayBoolean, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.arrayObject]: [ - WorkflowIOValueTypeEnum.object, - WorkflowIOValueTypeEnum.arrayObject, - WorkflowIOValueTypeEnum.arrayAny, - WorkflowIOValueTypeEnum.chatHistory, - WorkflowIOValueTypeEnum.datasetQuote, - WorkflowIOValueTypeEnum.dynamic, - WorkflowIOValueTypeEnum.selectDataset, - WorkflowIOValueTypeEnum.selectApp - ], - [WorkflowIOValueTypeEnum.chatHistory]: [ - WorkflowIOValueTypeEnum.chatHistory, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.datasetQuote]: [ - WorkflowIOValueTypeEnum.datasetQuote, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.dynamic]: [ - WorkflowIOValueTypeEnum.dynamic, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.selectDataset]: [ - WorkflowIOValueTypeEnum.selectDataset, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.selectApp]: [ - WorkflowIOValueTypeEnum.selectApp, - WorkflowIOValueTypeEnum.arrayAny - ], - [WorkflowIOValueTypeEnum.arrayAny]: [WorkflowIOValueTypeEnum.arrayAny], - [WorkflowIOValueTypeEnum.any]: [WorkflowIOValueTypeEnum.arrayAny] - }; - - return outputs.filter( - (output) => - valueType === WorkflowIOValueTypeEnum.any || - valueType === WorkflowIOValueTypeEnum.arrayAny || - !output.valueType || - output.valueType === WorkflowIOValueTypeEnum.any || - validTypeMap[valueType]?.includes(output.valueType) - ); -}; +): FlowNodeOutputItemType[] => + outputs.filter((output) => workflowValueTypeIsCompatible(output.valueType, valueType)); export type WorkflowReferenceSourceNode = { nodeId: string; diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index f9b3371cefcf..10489edddada 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -44,6 +44,7 @@ import { isAgentGeneratedToolInput } from '@fastgpt/global/core/app/formEdit/utils'; import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; +import { workflowValueTypeIsCompatible } from './utils'; type WorkflowCheckContext = { nodeMap: Map>; @@ -152,6 +153,7 @@ type WorkflowCheckMessageCode = | 'required_input_empty' | 'no_upstream' | 'invalid_reference' + | 'invalid_reference_type' | 'if_else_incomplete' | 'user_select_empty' | 'user_select_value_empty' @@ -174,6 +176,7 @@ const WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP: Record([ 'invalid_reference', + 'invalid_reference_type', 'tool_missing', 'tool_load_failed', 'tool_no_permission', @@ -217,6 +221,8 @@ const workflowCheckMessageFallback: Record< no_upstream: () => '未与其他节点连线', invalid_reference: ({ inputName } = {}) => `${inputName ?? ''} 中存在已被删除的变量,需处理`.trim(), + invalid_reference_type: ({ inputName } = {}) => + `${inputName ?? ''} 中引用的变量类型不匹配,需处理`.trim(), if_else_incomplete: () => '存在未完成的条件配置,请完善', user_select_empty: () => '需配置至少一个选项', user_select_value_empty: () => '选项不可为空', @@ -283,6 +289,8 @@ const translateWorkflowCheckIssueMessage = ( return t('common:core.workflow.check.no_upstream', params); case 'invalid_reference': return t('common:core.workflow.check.invalid_reference', params); + case 'invalid_reference_type': + return t('common:core.workflow.check.invalid_reference_type', params); case 'if_else_incomplete': return t('common:core.workflow.check.if_else_incomplete', params); case 'user_select_empty': @@ -463,6 +471,37 @@ const referenceValueHasInvalidSource = (value: unknown, context: WorkflowCheckCo isConfiguredReferenceValue(value) && getReferenceItems(value).some((item) => !referenceValueIsLive(item, context)); +/** 读取引用来源的当前 valueType:全局变量取 chatConfig 定义,普通节点取输出定义。 */ +const getReferenceSourceValueType = ( + value: ReferenceItemValueType, + context: WorkflowCheckContext +): WorkflowIOValueTypeEnum | undefined => { + const [refNodeId, refOutputId] = value; + if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return undefined; + if (refNodeId === VARIABLE_NODE_ID) { + return context.chatConfig?.variables?.find((variable) => variable.key === refOutputId) + ?.valueType; + } + return context.nodeOutputMap.get(refNodeId)?.get(refOutputId)?.valueType; +}; + +/** + * 判断已配置引用是否存在来源类型与目标类型不兼容的项。 + * 只检查来源仍 live 的引用,来源消失统一归 invalid_reference; + * 多选任一项不兼容即返回 true,与 invalid_reference 语义一致。 + */ +const referenceValueHasTypeMismatch = ( + value: unknown, + targetType: WorkflowIOValueTypeEnum | undefined, + context: WorkflowCheckContext +) => + isConfiguredReferenceValue(value) && + getReferenceItems(value).some( + (item) => + referenceValueIsLive(item, context) && + !workflowValueTypeIsCompatible(getReferenceSourceValueType(item, context), targetType) + ); + const isVariableUpdateTargetEmpty = ( variable: unknown, nodeIds: string[], @@ -493,6 +532,42 @@ const isVariableUpdateValueInvalid = (item: TUpdateListItem, context: WorkflowCh item.renderType === FlowNodeInputTypeEnum.reference && referenceValueHasInvalidSource(item.value, context); +/** + * 更新项目标变量的缓存 valueType 与变量当前类型不兼容时报类型问题。 + * 运行时按缓存类型格式化写入值,缓存过期即向新类型变量写入偏离语义的值; + * chatConfig 缺失时全局变量当前类型不可知,谓词按兼容处理,不误报。 + */ +const isVariableUpdateTargetTypeMismatch = ( + item: TUpdateListItem, + context: WorkflowCheckContext +) => { + if (!item.valueType) return false; + if (!isValidReferenceValueFormat(item.variable)) return false; + const currentType = getReferenceSourceValueType(item.variable as ReferenceItemValueType, context); + return !workflowValueTypeIsCompatible(item.valueType, currentType); +}; + +/** + * 更新项 reference 模式值与目标变量当前类型不兼容时报类型问题。 + * 目标变量已消失时由 invalid_reference 处理,不重复报类型问题。 + */ +const isVariableUpdateValueTypeMismatch = ( + item: TUpdateListItem, + context: WorkflowCheckContext +) => { + if (item.renderType !== FlowNodeInputTypeEnum.reference) return false; + if (!isConfiguredReferenceValue(item.value)) return false; + if (!isValidReferenceValueFormat(item.variable)) return false; + if (!referenceValueIsLive(item.variable as ReferenceItemValueType, context)) return false; + + const targetType = getReferenceSourceValueType(item.variable as ReferenceItemValueType, context); + return getReferenceItems(item.value).some( + (refItem) => + referenceValueIsLive(refItem, context) && + !workflowValueTypeIsCompatible(getReferenceSourceValueType(refItem, context), targetType) + ); +}; + /** * 结构化校验工作流节点和连线。 * 函数只读取入参并返回每个节点的错误列表,调用方负责写入 React state、toast 或定位画布。 @@ -773,7 +848,7 @@ export const checkWorkflowNodeIssues = ({ }: { field: 'variable' | 'value'; index?: number; - code: 'required_input_empty' | 'invalid_reference'; + code: 'required_input_empty' | 'invalid_reference' | 'invalid_reference_type'; }) => { const inputName = (() => { if (field === 'variable') { @@ -790,7 +865,7 @@ export const checkWorkflowNodeIssues = ({ inputName }), inputKey: - code === 'invalid_reference' && index !== undefined + code !== 'required_input_empty' && index !== undefined ? `${NodeInputKeyEnum.updateList}[${index}].${field}` : NodeInputKeyEnum.updateList }); @@ -809,6 +884,12 @@ export const checkWorkflowNodeIssues = ({ ? 'invalid_reference' : 'required_input_empty' }); + } else if (isVariableUpdateTargetTypeMismatch(item, context)) { + addVariableUpdateIssue({ + field: 'variable', + index, + code: 'invalid_reference_type' + }); } if (isVariableUpdateValueEmpty(item, context)) { addVariableUpdateIssue({ @@ -818,6 +899,12 @@ export const checkWorkflowNodeIssues = ({ ? 'invalid_reference' : 'required_input_empty' }); + } else if (isVariableUpdateValueTypeMismatch(item, context)) { + addVariableUpdateIssue({ + field: 'value', + index, + code: 'invalid_reference_type' + }); } }); } @@ -851,6 +938,18 @@ export const checkWorkflowNodeIssues = ({ }), inputKey: input.key }); + } else if ( + isReferenceInput && + referenceValueHasTypeMismatch(effectiveInputValue, input.valueType, context) + ) { + addIssue({ + node, + code: 'invalid_reference_type', + message: getWorkflowCheckIssueMessage('invalid_reference_type', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); } if (shouldSkipGenericRequiredInputCheck(input)) { @@ -931,6 +1030,32 @@ export const checkWorkflowNodeIssues = ({ export const checkWorkflowHasError = (nodeIssueMap: WorkflowCheckNodeIssueMap) => Object.values(nodeIssueMap).some((issues) => issues.some((issue) => issue.level === 'error')); +/** + * 对比节点上一次检查结果,统计指定 code 的新增问题数量。 + * 用于类型等配置变化后聚合提示一次,不对已存在的问题重复提醒。 + */ +export const countNewWorkflowCheckIssues = ({ + issueMap, + prevNodes, + code +}: { + issueMap: WorkflowCheckNodeIssueMap; + prevNodes: Array<{ data: FlowNodeItemType }>; + code: string; +}) => { + let count = 0; + for (const [nodeId, issues] of Object.entries(issueMap)) { + const prevIssues = prevNodes.find((node) => node.data.nodeId === nodeId)?.data + .workflowCheckIssues; + count += issues.filter( + (issue) => + issue.code === code && + !prevIssues?.some((prev) => prev.code === issue.code && prev.inputKey === issue.inputKey) + ).length; + } + return count; +}; + /** 返回存在 error 的 nodeId 列表;传入 nodeOrder 时按画布节点顺序排列,便于稳定定位第一个错误节点。 */ export const getWorkflowCheckErrorNodeIds = ( nodeIssueMap: WorkflowCheckNodeIssueMap, 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 5195b569522e..a0168a952b79 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -375,6 +375,301 @@ describe('checkWorkflowNodeIssues', () => { ); }); + describe('invalid_reference_type', () => { + const typedSourceNode = makeNode('typed-source', FlowNodeTypeEnum.workflowStart, { + outputs: [ + { + id: 'text', + key: 'text', + label: 'text', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + }, + { + id: 'count', + key: 'count', + label: 'count', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.number + }, + { + id: 'untyped', + key: 'untyped', + label: 'untyped', + type: FlowNodeOutputTypeEnum.static + } + ] + }); + const consumerEdge = [ + { id: 'e-typed', source: 'typed-source', target: 'consumer', type: EDGE_TYPE } + ]; + + const makeReferenceInput = ( + key: string, + value: unknown, + valueType?: WorkflowIOValueTypeEnum + ) => ({ + key, + label: key, + valueType, + renderTypeList: [FlowNodeInputTypeEnum.reference], + value + }); + + it('keeps type-compatible references without issues', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { + inputs: [ + makeReferenceInput('same-type', ['typed-source', 'text'], WorkflowIOValueTypeEnum.string), + // arrayString 目标按 validTypeMap 接受 string 元素来源 + makeReferenceInput( + 'array-accepts-element', + ['typed-source', 'text'], + WorkflowIOValueTypeEnum.arrayString + ), + makeReferenceInput('any-input', ['typed-source', 'count'], WorkflowIOValueTypeEnum.any), + makeReferenceInput( + 'untyped-source', + ['typed-source', 'untyped'], + WorkflowIOValueTypeEnum.number + ), + makeReferenceInput('untyped-input', ['typed-source', 'count']) + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge + }); + + expect(result.consumer?.some((issue) => issue.code === 'invalid_reference_type')).toBeFalsy(); + }); + + it('reports type mismatch for a single reference input', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { + inputs: [ + makeReferenceInput('bad-type', ['typed-source', 'count'], WorkflowIOValueTypeEnum.string) + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge + }); + + expect(result.consumer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_reference_type', inputKey: 'bad-type' }) + ]) + ); + }); + + it('reports type mismatch for a global variable reference', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { + inputs: [ + makeReferenceInput( + 'var-type', + [VARIABLE_NODE_ID, 'num-var'], + WorkflowIOValueTypeEnum.string + ) + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge, + chatConfig: { + variables: [{ key: 'num-var', valueType: WorkflowIOValueTypeEnum.number }] + } as any + }); + + expect(result.consumer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_reference_type', inputKey: 'var-type' }) + ]) + ); + }); + + it('reports type mismatch when any item of a multi reference is incompatible', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { + inputs: [ + makeReferenceInput( + 'mixed-array', + [ + ['typed-source', 'text'], + ['typed-source', 'count'] + ], + WorkflowIOValueTypeEnum.arrayString + ) + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge + }); + + expect(result.consumer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_reference_type', inputKey: 'mixed-array' }) + ]) + ); + }); + + it('keeps invalid_reference as the only issue when the source no longer exists', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('gone', ['ghost-node', 'out'], WorkflowIOValueTypeEnum.string)] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge + }); + + const codes = result.consumer?.map((issue) => issue.code) ?? []; + expect(codes).toContain('invalid_reference'); + expect(codes).not.toContain('invalid_reference_type'); + }); + + const makeVarUpdateNode = (updateList: any[]) => + makeNode('consumer', FlowNodeTypeEnum.variableUpdate, { + inputs: [ + { + key: NodeInputKeyEnum.updateList, + valueType: WorkflowIOValueTypeEnum.any, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + value: updateList + } + ] + }); + + it('reports VariableUpdate value reference type mismatch against the current variable type', () => { + const node = makeVarUpdateNode([ + { + variable: [VARIABLE_NODE_ID, 'str-var'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference, + value: ['typed-source', 'count'] + } + ]); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge, + chatConfig: { + variables: [{ key: 'str-var', valueType: WorkflowIOValueTypeEnum.string }] + } as any + }); + + expect(result.consumer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_reference_type', + inputKey: `${NodeInputKeyEnum.updateList}[0].value` + }) + ]) + ); + }); + + it('keeps VariableUpdate value reference when the type is compatible', () => { + const node = makeVarUpdateNode([ + { + variable: [VARIABLE_NODE_ID, 'str-var'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference, + value: ['typed-source', 'text'] + } + ]); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge, + chatConfig: { + variables: [{ key: 'str-var', valueType: WorkflowIOValueTypeEnum.string }] + } as any + }); + + expect(result.consumer?.some((issue) => issue.code === 'invalid_reference_type')).toBeFalsy(); + }); + + it('reports VariableUpdate target when cached valueType no longer matches the variable type', () => { + const node = makeVarUpdateNode([ + { + variable: [VARIABLE_NODE_ID, 'now-string'], + valueType: WorkflowIOValueTypeEnum.number, + numberOperator: '+', + renderType: FlowNodeInputTypeEnum.input, + value: ['', '5'] + } + ]); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge, + chatConfig: { + variables: [{ key: 'now-string', valueType: WorkflowIOValueTypeEnum.string }] + } as any + }); + + expect(result.consumer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_reference_type', + inputKey: `${NodeInputKeyEnum.updateList}[0].variable` + }) + ]) + ); + }); + + it('keeps VariableUpdate target when cached valueType is still compatible', () => { + const node = makeVarUpdateNode([ + { + // string 缓存写入 arrayString 变量:按 validTypeMap 兼容 + variable: [VARIABLE_NODE_ID, 'arr-var'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.input, + value: ['', 'x'] + }, + { + variable: [VARIABLE_NODE_ID, 'any-var'], + valueType: WorkflowIOValueTypeEnum.number, + renderType: FlowNodeInputTypeEnum.input, + value: ['', '1'] + } + ]); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge, + chatConfig: { + variables: [ + { key: 'arr-var', valueType: WorkflowIOValueTypeEnum.arrayString }, + { key: 'any-var', valueType: WorkflowIOValueTypeEnum.any } + ] + } as any + }); + + expect(result.consumer?.some((issue) => issue.code === 'invalid_reference_type')).toBeFalsy(); + }); + + it('skips VariableUpdate type checks when chat config is absent', () => { + const node = makeVarUpdateNode([ + { + variable: [VARIABLE_NODE_ID, 'foo'], + valueType: WorkflowIOValueTypeEnum.number, + renderType: FlowNodeInputTypeEnum.reference, + value: [[VARIABLE_NODE_ID, 'bar']] + } + ]); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, node], + edges: consumerEdge + }); + + expect(result.consumer?.some((issue) => issue.code === 'invalid_reference_type')).toBeFalsy(); + }); + }); + it('preserves invalid single and multiple references when storing workflow data', () => { const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, { outputs: [ From b66e96d4e51cefb4425336a6374fd9efba8a4953 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Thu, 27 Aug 2026 10:54:54 +0800 Subject: [PATCH 03/18] feat(workflow): unreachable reference check --- packages/web/i18n/en/common.json | 2 + packages/web/i18n/ko-KR/common.json | 2 + packages/web/i18n/zh-CN/common.json | 2 + packages/web/i18n/zh-Hant/common.json | 2 + .../RenderInput/templates/Reference.tsx | 87 ++++- .../context/workflowActionsContext.tsx | 48 ++- .../src/web/core/workflow/workflowCheck.ts | 164 +++++++++- .../test/web/core/app/workflow/utils.test.ts | 307 ++++++++++++++++++ 8 files changed, 601 insertions(+), 13 deletions(-) diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index fc6a13fb0784..bfaf5cab35cb 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -657,6 +657,7 @@ "core.workflow.check.no_upstream": "Not connected to other nodes", "core.workflow.check.reference_deleted": "Variable deleted", "core.workflow.check.reference_type_mismatch": "Variable type mismatch", + "core.workflow.check.reference_unreachable": "Source disconnected", "core.workflow.check.required_input_empty": "Please fill in required field {{inputName}}", "core.workflow.check.status.pending_handle": "To handle", "core.workflow.check.status.pending_improve": "To complete", @@ -665,6 +666,7 @@ "core.workflow.check.tool_load_failed": "Failed to load this tool, please try again later", "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.unreachable_reference": "{{inputName}} references a source that is no longer connected. Action required.", "core.workflow.check.user_select_empty": "Configure at least one option", "core.workflow.check.user_select_value_empty": "Option cannot be empty", "core.workflow.debug.Done": "Debugging Completed", diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index d73c8ecbef5e..5b734dd46262 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -655,6 +655,7 @@ "core.workflow.check.no_upstream": "다른 노드와 연결되지 않았습니다", "core.workflow.check.reference_deleted": "변수 삭제됨", "core.workflow.check.reference_type_mismatch": "변수 유형 불일치", + "core.workflow.check.reference_unreachable": "출처 연결 끊김", "core.workflow.check.required_input_empty": "필수 항목 {{inputName}}을(를) 입력하세요", "core.workflow.check.status.pending_handle": "처리 대기", "core.workflow.check.status.pending_improve": "보완 필요", @@ -663,6 +664,7 @@ "core.workflow.check.tool_load_failed": "도구를 불러오지 못했습니다. 잠시 후 다시 시도하세요", "core.workflow.check.tool_missing": "이 도구는 존재하지 않습니다. 삭제하세요", "core.workflow.check.tool_no_permission": "현재 계정에 이 리소스에 접근할 권한이 없습니다", + "core.workflow.check.unreachable_reference": "{{inputName}}에 참조하는 출처가 더 이상 연결 범위 내에 없습니다. 처리가 필요합니다.", "core.workflow.check.user_select_empty": "옵션을 하나 이상 설정하세요", "core.workflow.check.user_select_value_empty": "옵션 값은 비워 둘 수 없습니다", "core.workflow.debug.Done": "디버깅 완료", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index e2374787a603..f15de89f9b19 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -657,6 +657,7 @@ "core.workflow.check.no_upstream": "未与其他节点连线", "core.workflow.check.reference_deleted": "变量已删除", "core.workflow.check.reference_type_mismatch": "变量类型不匹配", + "core.workflow.check.reference_unreachable": "来源连线已断开", "core.workflow.check.required_input_empty": "需填写必填项 {{inputName}}", "core.workflow.check.status.pending_handle": "待处理", "core.workflow.check.status.pending_improve": "待完善", @@ -665,6 +666,7 @@ "core.workflow.check.tool_load_failed": "工具加载失败,请稍后重试", "core.workflow.check.tool_missing": "该工具不存在,请删除", "core.workflow.check.tool_no_permission": "当前账号无权限访问该资源", + "core.workflow.check.unreachable_reference": "{{inputName}} 中引用的来源已不在当前连线范围内,需处理", "core.workflow.check.user_select_empty": "需配置至少一个选项", "core.workflow.check.user_select_value_empty": "选项不可为空", "core.workflow.debug.Done": "完成调试", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 5ec88bcfeb20..eb1d91155ceb 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -657,6 +657,7 @@ "core.workflow.check.no_upstream": "未與其他節點連線", "core.workflow.check.reference_deleted": "變量已刪除", "core.workflow.check.reference_type_mismatch": "變量類型不匹配", + "core.workflow.check.reference_unreachable": "來源連線已中斷", "core.workflow.check.required_input_empty": "需填寫必填項 {{inputName}}", "core.workflow.check.status.pending_handle": "待處理", "core.workflow.check.status.pending_improve": "待完善", @@ -665,6 +666,7 @@ "core.workflow.check.tool_load_failed": "工具載入失敗,請稍後重試", "core.workflow.check.tool_missing": "該工具不存在,請刪除", "core.workflow.check.tool_no_permission": "當前帳號無權限存取該資源", + "core.workflow.check.unreachable_reference": "{{inputName}} 中引用的來源已不在當前連線範圍內,需處理", "core.workflow.check.user_select_empty": "需配置至少一個選項", "core.workflow.check.user_select_value_empty": "選項不可為空", "core.workflow.debug.Done": "除錯完成", diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index cb188124f7be..73517d4dd693 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -5,16 +5,24 @@ import MyIcon from '@fastgpt/web/components/common/Icon'; import { getNodeAllSource, filterSelectableWorkflowNodeOutputs } from '@/web/core/workflow/utils'; import { isConfiguredReferenceValue } from '@/web/core/workflow/workflowCheck'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; -import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { + WorkflowIOValueTypeEnum, + VARIABLE_NODE_ID, + NodeOutputKeyEnum +} from '@fastgpt/global/core/workflow/constants'; import type { ReferenceArrayValueType, ReferenceItemValueType, ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import dynamic from 'next/dynamic'; import { useContextSelector } from 'use-context-selector'; -import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant'; +import { + isNestedParentNodeType, + FlowNodeOutputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; import { AppContext } from '@/pageComponents/app/detail/context'; import { WorkflowBufferDataContext } from '../../../../../context/workflowInitContext'; import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext'; @@ -32,6 +40,35 @@ const MultipleRowArraySelect = dynamic(() => ); const Avatar = dynamic(() => import('@fastgpt/web/components/common/Avatar')); +/** + * 判断引用指向的来源节点和输出是否仍存在(不依赖连线关系)。 + * 选择器列表只包含当前连线范围内的来源,凭此区分「来源已删除」和「断边导致来源不可达」: + * 来源仍存在但不在可选列表中,说明是连线断开导致不可选。 + */ +const referenceSourceStillExists = ({ + value, + getNodeById +}: { + value?: ReferenceItemValueType; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; +}) => { + if (!value) return false; + const [refNodeId, refOutputId] = value; + if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return false; + if (!refNodeId || !refOutputId || refNodeId === VARIABLE_NODE_ID) return false; + + const sourceNode = getNodeById(refNodeId); + if (!sourceNode) return false; + + const output = sourceNode.outputs.find((item) => item.id === refOutputId); + if (!output || output.invalid === true || output.id === NodeOutputKeyEnum.addOutputParam) { + return false; + } + if (output.type === FlowNodeOutputTypeEnum.error) return sourceNode.catchError === true; + + return true; +}; + type ReferenceSelectList = { label: string | React.ReactNode; value: string; @@ -211,6 +248,7 @@ const SingleReferenceSelector = ({ ButtonProps }: SelectProps) => { const { t } = useSafeTranslation(); + const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); const getSelectValue = useCallback( (value: ReferenceValueType, searchList: ReferenceSelectList) => { @@ -253,6 +291,11 @@ const SingleReferenceSelector = ({ ? getSelectValue(selectorVal, liveList) : []; const isTypeMismatch = isInvalidReference && Boolean(liveNodeName && liveOutputName); + // 来源节点仍存在但不在可选列表中,即断边导致不可达;否则按来源已删除提示 + const isSourceUnreachable = + isInvalidReference && + !isTypeMismatch && + referenceSourceStillExists({ value: selectorVal, getNodeById }); return ( ) @@ -284,7 +329,18 @@ const SingleReferenceSelector = ({ ButtonProps={ButtonProps} /> ); - }, [ButtonProps, getSelectValue, list, liveList, onSelect, placeholder, popDirection, t, value]); + }, [ + ButtonProps, + getSelectValue, + getNodeById, + list, + liveList, + onSelect, + placeholder, + popDirection, + t, + value + ]); return ItemSelector; }; @@ -297,6 +353,7 @@ const MultipleReferenceSelector = ({ popDirection }: SelectProps) => { const { t } = useSafeTranslation(); + const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); const getSelectValue = useCallback( (value: ReferenceValueType, searchList: ReferenceSelectList) => { @@ -363,6 +420,11 @@ const MultipleReferenceSelector = ({ : []; const isTypeMismatch = isInvalidReference && Boolean(liveNodeName && liveOutputName); + // 来源节点仍存在但不在可选列表中,即断边导致不可达;否则按来源已删除提示 + const isSourceUnreachable = + isInvalidReference && + !isTypeMismatch && + referenceSourceStillExists({ value: rawValue, getNodeById }); return ( {isTypeMismatch ? t('common:core.workflow.check.reference_type_mismatch') - : t('common:core.workflow.check.reference_deleted')} + : isSourceUnreachable + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted')} ) : ( {placeholder} @@ -430,7 +494,18 @@ const MultipleReferenceSelector = ({ popDirection={popDirection} /> ); - }, [formatList, getSelectValue, list, liveList, onSelect, placeholder, popDirection, t, value]); + }, [ + formatList, + getSelectValue, + getNodeById, + list, + liveList, + onSelect, + placeholder, + popDirection, + t, + value + ]); return ArraySelector; }; 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 0e561b0d9457..4ca5191e15c4 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import type { OnConnectStartParams } from 'reactflow'; +import type { Edge } from 'reactflow'; import { WorkflowBufferDataContext } from './workflowInitContext'; import type { FlowNodeInputItemType, @@ -153,6 +154,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const singleNodeCheckTimerRef = useRef>>(new Map()); const edgeCheckTimerRef = useRef | null>(null); + // 删边影响范围:从被删边 target 沿剩余出边向下扩展;防抖检查时消费后清空 + const edgeImpactNodeIdsRef = useRef>(new Set()); const isFirstEdgesEffectRef = useRef(true); const prevEdgesRef = useRef(edges); @@ -305,11 +308,16 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const nodes = getNodes(); if (nodes.length === 0) return; + // 存在删边影响范围时追加断边引用检查;普通连线变化保持原有全量检查行为 + const impactNodeIds = edgeImpactNodeIdsRef.current; + edgeImpactNodeIdsRef.current = new Set(); + const issueMap = checkWorkflowNodeIssues({ nodes, edges, t, - chatConfig: appDetail.chatConfig + chatConfig: appDetail.chatConfig, + referenceNodeIds: impactNodeIds.size > 0 ? [...impactNodeIds] : undefined }); // 输出/catchError 变化产生新增类型不兼容时聚合提示一次,已有问题不重复提醒 @@ -329,6 +337,30 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }, 400); }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); + /** + * 从被删边的 target 出发,仅沿删除后的剩余出边向下遍历,得到断边影响节点集合。 + * 影响集合用于限定 unreachable_reference 判定范围,不维护持久化引用索引。 + */ + const collectEdgeDeletionImpactNodeIds = useCallback( + (removedEdges: Edge[], remainingEdges: Edge[]) => { + const impactNodeIds = new Set(); + const queue = removedEdges.map((edge) => edge.target); + + while (queue.length > 0) { + const nodeId = queue.shift(); + if (!nodeId || impactNodeIds.has(nodeId)) continue; + impactNodeIds.add(nodeId); + + remainingEdges.forEach((edge) => { + if (edge.source === nodeId) queue.push(edge.target); + }); + } + + return impactNodeIds; + }, + [] + ); + useEffect(() => { if (isFirstEdgesEffectRef.current) { isFirstEdgesEffectRef.current = false; @@ -371,10 +403,21 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }) ); } + + // 400ms 内可能连续删多条边,影响范围跨变化累积,由防抖检查统一消费 + collectEdgeDeletionImpactNodeIds(removedEdges, edges).forEach((nodeId) => + edgeImpactNodeIdsRef.current.add(nodeId) + ); } scheduleWorkflowCheckOnEdgeChange(); - }, [edges, scheduleWorkflowCheckOnEdgeChange, getNodes, setNodes]); + }, [ + edges, + scheduleWorkflowCheckOnEdgeChange, + getNodes, + setNodes, + collectEdgeDeletionImpactNodeIds + ]); useEffect(() => { const timers = singleNodeCheckTimerRef.current; @@ -384,6 +427,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod if (edgeCheckTimerRef.current) { clearTimeout(edgeCheckTimerRef.current); } + edgeImpactNodeIdsRef.current = new Set(); }; }, []); diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index 10489edddada..c2f5fd78b3a5 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -53,6 +53,12 @@ type WorkflowCheckContext = { outgoingEdgesMap: Map[]>; reachableNodeSet: Set; chatConfig?: AppChatConfigType; + // 未按 sourceHandle 过滤的入边表,来源集合遍历需要与编辑器 getNodeAllSource 一致 + rawIncomingEdgesMap: Map[]>; + // 容器(Loop/ParallelRun)按 parentNodeId 归组的 children + childrenMap: Map; + // 删边影响范围;缺省时不产出 unreachable_reference,保持全量检查行为不变 + referenceNodeIdSet?: Set; }; const workflowCheckSkipConnectionTypes = new Set([ @@ -154,6 +160,7 @@ type WorkflowCheckMessageCode = | 'no_upstream' | 'invalid_reference' | 'invalid_reference_type' + | 'unreachable_reference' | 'if_else_incomplete' | 'user_select_empty' | 'user_select_value_empty' @@ -177,6 +184,7 @@ const WORKFLOW_CHECK_ISSUE_MESSAGE_CODE_MAP: Record([ 'invalid_reference', 'invalid_reference_type', + 'unreachable_reference', 'tool_missing', 'tool_load_failed', 'tool_no_permission', @@ -223,6 +232,8 @@ const workflowCheckMessageFallback: Record< `${inputName ?? ''} 中存在已被删除的变量,需处理`.trim(), invalid_reference_type: ({ inputName } = {}) => `${inputName ?? ''} 中引用的变量类型不匹配,需处理`.trim(), + unreachable_reference: ({ inputName } = {}) => + `${inputName ?? ''} 中引用的来源已不在当前连线范围内,需处理`.trim(), if_else_incomplete: () => '存在未完成的条件配置,请完善', user_select_empty: () => '需配置至少一个选项', user_select_value_empty: () => '选项不可为空', @@ -291,6 +302,8 @@ const translateWorkflowCheckIssueMessage = ( return t('common:core.workflow.check.invalid_reference', params); case 'invalid_reference_type': return t('common:core.workflow.check.invalid_reference_type', params); + case 'unreachable_reference': + return t('common:core.workflow.check.unreachable_reference', params); case 'if_else_incomplete': return t('common:core.workflow.check.if_else_incomplete', params); case 'user_select_empty': @@ -340,16 +353,20 @@ export const getWorkflowCheckIssueMessage = ( const createWorkflowCheckContext = ({ nodes, edges, - chatConfig + chatConfig, + referenceNodeIds }: { nodes: Node[]; edges: Edge[]; chatConfig?: AppChatConfigType; + referenceNodeIds?: string[]; }): WorkflowCheckContext => { const nodeMap = new Map>(); const nodeOutputMap = new Map>(); const incomingEdgesMap = new Map[]>(); const outgoingEdgesMap = new Map[]>(); + const rawIncomingEdgesMap = new Map[]>(); + const childrenMap = new Map(); nodes.forEach((node) => { nodeMap.set(node.data.nodeId, node); @@ -359,9 +376,20 @@ const createWorkflowCheckContext = ({ ); incomingEdgesMap.set(node.data.nodeId, []); outgoingEdgesMap.set(node.data.nodeId, []); + rawIncomingEdgesMap.set(node.data.nodeId, []); + + const parentId = node.data.parentNodeId; + if (parentId) { + childrenMap.set(parentId, [...(childrenMap.get(parentId) ?? []), node.data.nodeId]); + } }); edges.forEach((edge) => { + // 来源集合遍历不做 sourceHandle 过滤,与编辑器选择器看到的连线保持一致 + if (nodeMap.has(edge.source) && nodeMap.has(edge.target)) { + rawIncomingEdgesMap.get(edge.target)?.push(edge); + } + const sourceNode = nodeMap.get(edge.source); if (!isWorkflowEdgeSourceHandleValid(sourceNode, edge.sourceHandle)) { return; @@ -396,7 +424,10 @@ const createWorkflowCheckContext = ({ incomingEdgesMap, outgoingEdgesMap, reachableNodeSet, - chatConfig + chatConfig, + rawIncomingEdgesMap, + childrenMap, + referenceNodeIdSet: referenceNodeIds ? new Set(referenceNodeIds) : undefined }; }; @@ -471,6 +502,95 @@ const referenceValueHasInvalidSource = (value: unknown, context: WorkflowCheckCo isConfiguredReferenceValue(value) && getReferenceItems(value).some((item) => !referenceValueIsLive(item, context)); +/** + * 收集节点当前可引用的来源节点集合,遍历规则与编辑器 getNodeAllSource 保持一致: + * 沿入边反向遍历,嵌套节点追加父容器入边和父容器 reference 输入指向的外部节点, + * 容器自身 children 也属于可选来源。全局变量不依赖连线,按 VARIABLE_NODE_ID 单独判定。 + */ +const getNodeReferenceSourceSet = ( + node: Node, + context: WorkflowCheckContext +): Set => { + const sourceSet = new Set(); + const visited = new Set(); + + const walkIncoming = (nodeId: string) => { + if (visited.has(nodeId)) return; + visited.add(nodeId); + + context.rawIncomingEdgesMap.get(nodeId)?.forEach((edge) => { + sourceSet.add(edge.source); + walkIncoming(edge.source); + }); + }; + + walkIncoming(node.data.nodeId); + + const parentId = node.data.parentNodeId; + if (parentId) { + walkIncoming(parentId); + + // 容器 reference 输入不产生 ReactFlow edge,被引用的外部节点需显式补充,避免误报 + context.nodeMap.get(parentId)?.data.inputs.forEach((input) => { + if (!nodeInputIsReference(input)) return; + const value = input.value; + if (!Array.isArray(value)) return; + const [refNodeId] = value as ReferenceItemValueType; + if (typeof refNodeId !== 'string' || !refNodeId || refNodeId === VARIABLE_NODE_ID) return; + if (!context.nodeMap.has(refNodeId) || sourceSet.has(refNodeId)) return; + sourceSet.add(refNodeId); + walkIncoming(refNodeId); + }); + } + + context.childrenMap.get(node.data.nodeId)?.forEach((childId) => sourceSet.add(childId)); + + return sourceSet; +}; + +/** + * 判断单个仍 live 的引用项是否因删除边而不可达: + * 来源节点不在当前节点允许来源集合内,或来源节点已无法从工作流入口到达。 + * 来源消失由 invalid_reference 处理;全局变量恒不视为不可达。 + */ +const referenceItemIsUnreachable = ( + value: ReferenceItemValueType, + sourceSet: Set, + context: WorkflowCheckContext +) => { + if (!referenceValueIsLive(value, context)) return false; + const [refNodeId] = value; + if (refNodeId === VARIABLE_NODE_ID) return false; + if (!sourceSet.has(refNodeId)) return true; + return !context.reachableNodeSet.has(refNodeId); +}; + +/** 已配置引用中任一项不可达即报字段级问题,与 invalid_reference 语义一致。 */ +const referenceValueHasUnreachableSource = ( + value: unknown, + sourceSet: Set | undefined, + context: WorkflowCheckContext +) => + !!sourceSet && + isConfiguredReferenceValue(value) && + getReferenceItems(value).some((item) => referenceItemIsUnreachable(item, sourceSet, context)); + +const isVariableUpdateValueUnreachable = ( + item: TUpdateListItem, + sourceSet: Set | undefined, + context: WorkflowCheckContext +) => + item.renderType === FlowNodeInputTypeEnum.reference && + referenceValueHasUnreachableSource(item.value, sourceSet, context); + +const isVariableUpdateTargetUnreachable = ( + item: TUpdateListItem, + sourceSet: Set | undefined, + context: WorkflowCheckContext +) => + !!sourceSet && + referenceItemIsUnreachable(item.variable as ReferenceItemValueType, sourceSet, context); + /** 读取引用来源的当前 valueType:全局变量取 chatConfig 定义,普通节点取输出定义。 */ const getReferenceSourceValueType = ( value: ReferenceItemValueType, @@ -577,15 +697,17 @@ export const checkWorkflowNodeIssues = ({ edges, nodeId, t, - chatConfig + chatConfig, + referenceNodeIds }: { nodes: Node[]; edges: Edge[]; nodeId?: string; t?: TFunction; chatConfig?: AppChatConfigType; + referenceNodeIds?: string[]; }): WorkflowCheckNodeIssueMap => { - const context = createWorkflowCheckContext({ nodes, edges, chatConfig }); + const context = createWorkflowCheckContext({ nodes, edges, chatConfig, referenceNodeIds }); const issueMap: WorkflowCheckNodeIssueMap = {}; const nodeIds = nodes.map((node) => node.data.nodeId); const targetNodes = nodeId ? nodes.filter((node) => node.data.nodeId === nodeId) : nodes; @@ -617,6 +739,10 @@ export const checkWorkflowNodeIssues = ({ const data = node.data; const inputs = data.inputs; const inputMap = new Map(inputs.map((input) => [input.key, input])); + // 仅删边影响范围内的节点追加断边不可达判定,全量检查不引入该规则 + const referenceSourceSet = context.referenceNodeIdSet?.has(data.nodeId) + ? getNodeReferenceSourceSet(node, context) + : undefined; const isToolNode = context.incomingEdgesMap .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); @@ -848,7 +974,11 @@ export const checkWorkflowNodeIssues = ({ }: { field: 'variable' | 'value'; index?: number; - code: 'required_input_empty' | 'invalid_reference' | 'invalid_reference_type'; + code: + | 'required_input_empty' + | 'invalid_reference' + | 'invalid_reference_type' + | 'unreachable_reference'; }) => { const inputName = (() => { if (field === 'variable') { @@ -884,6 +1014,12 @@ export const checkWorkflowNodeIssues = ({ ? 'invalid_reference' : 'required_input_empty' }); + } else if (isVariableUpdateTargetUnreachable(item, referenceSourceSet, context)) { + addVariableUpdateIssue({ + field: 'variable', + index, + code: 'unreachable_reference' + }); } else if (isVariableUpdateTargetTypeMismatch(item, context)) { addVariableUpdateIssue({ field: 'variable', @@ -899,6 +1035,12 @@ export const checkWorkflowNodeIssues = ({ ? 'invalid_reference' : 'required_input_empty' }); + } else if (isVariableUpdateValueUnreachable(item, referenceSourceSet, context)) { + addVariableUpdateIssue({ + field: 'value', + index, + code: 'unreachable_reference' + }); } else if (isVariableUpdateValueTypeMismatch(item, context)) { addVariableUpdateIssue({ field: 'value', @@ -938,6 +1080,18 @@ export const checkWorkflowNodeIssues = ({ }), inputKey: input.key }); + } else if ( + isReferenceInput && + referenceValueHasUnreachableSource(effectiveInputValue, referenceSourceSet, context) + ) { + addIssue({ + node, + code: 'unreachable_reference', + message: getWorkflowCheckIssueMessage('unreachable_reference', t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key + }); } else if ( isReferenceInput && referenceValueHasTypeMismatch(effectiveInputValue, input.valueType, context) 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 a0168a952b79..a04928937e74 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -670,6 +670,313 @@ describe('checkWorkflowNodeIssues', () => { }); }); + describe('unreachable_reference', () => { + const makeOutputNode = (nodeId: string): Node => + makeNode(nodeId, FlowNodeTypeEnum.answerNode, { + outputs: [ + { + id: 'out', + key: 'out', + label: 'out', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + + const makeReferenceInput = (key: string, value: unknown) => ({ + key, + label: key, + renderTypeList: [FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + value + }); + + // 链式 A -> B -> C -> D 删除 A -> B 后:B、C、D 进入影响范围 + const chainNodesAndEdges = () => { + const nodeA = makeOutputNode('A'); + const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-a', ['A', 'out'])], + outputs: [ + { + id: 'out', + key: 'out', + label: 'out', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + const nodeC = makeNode('C', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-a', ['A', 'out'])], + outputs: [ + { + id: 'out', + key: 'out', + label: 'out', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-b', ['B', 'out'])] + }); + + return { + nodes: [startNode, nodeA, nodeB, nodeC, nodeD], + // A -> B 已删除,仅剩 start -> A、B -> C、C -> D + edges: [ + { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, + { id: 'e-b-c', source: 'B', target: 'C', type: EDGE_TYPE }, + { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } + ] + }; + }; + + it('marks references unreachable in multi-level downstream after edge deletion', () => { + const { nodes, edges } = chainNodesAndEdges(); + + const result = checkWorkflowNodeIssues({ + nodes, + edges, + referenceNodeIds: ['B', 'C', 'D'] + }); + + // B、C 引用的 A 仍存在,但已不在允许来源范围内 + expect(result.B).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) + ]) + ); + expect(result.C).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) + ]) + ); + // D 引用的 B 仍在来源集合中,但 B 已不可从入口到达 + expect(result.D).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-b' }) + ]) + ); + }); + + it('does not report unreachable references without referenceNodeIds scope', () => { + const { nodes, edges } = chainNodesAndEdges(); + + const result = checkWorkflowNodeIssues({ nodes, edges }); + + ['B', 'C', 'D'].forEach((nodeId) => { + expect(result[nodeId]?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + }); + }); + + it('only reports unreachable references for nodes inside the impact scope', () => { + const { nodes, edges } = chainNodesAndEdges(); + + const result = checkWorkflowNodeIssues({ + nodes, + edges, + referenceNodeIds: ['B'] + }); + + expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBe(true); + expect(result.C?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + expect(result.D?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + }); + + it('only rechecks the deleted branch, not sibling branches', () => { + const nodeA = makeOutputNode('A'); + const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-a', ['A', 'out'])] + }); + const nodeC = makeNode('C', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-a', ['A', 'out'])] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeA, nodeB, nodeC], + // A -> B 已删除,A -> C 保留 + edges: [ + { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, + { id: 'e-a-c', source: 'A', target: 'C', type: EDGE_TYPE } + ], + referenceNodeIds: ['B'] + }); + + expect(result.B).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) + ]) + ); + expect(result.C?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + }); + + it('keeps merge references valid when an alternative source remains reachable', () => { + const nodeA = makeOutputNode('A'); + const nodeB = makeOutputNode('B'); + const nodeC = makeOutputNode('C'); + const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { + inputs: [ + makeReferenceInput('ref-b', ['B', 'out']), + makeReferenceInput('ref-c', ['C', 'out']) + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeA, nodeB, nodeC, nodeD], + // A -> B 已删除;C -> D 替代来源保留 + edges: [ + { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, + { id: 'e-start-c', source: 'start', target: 'C', type: EDGE_TYPE }, + { id: 'e-b-d', source: 'B', target: 'D', type: EDGE_TYPE }, + { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } + ], + referenceNodeIds: ['B', 'D'] + }); + + // D 引用的 B 仍存在但不再可达;引用的 C 仍可通过替代路径获取 + expect(result.D).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-b' }) + ]) + ); + expect( + result.D?.some( + (issue) => issue.code !== 'required_input_empty' && issue.inputKey === 'ref-c' + ) + ).toBeFalsy(); + }); + + it('keeps invalid_reference priority when the source itself is gone', () => { + const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-ghost', ['ghost', 'out'])] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeB], + edges: [{ id: 'e-start-b', source: 'start', target: 'B', type: EDGE_TYPE }], + referenceNodeIds: ['B'] + }); + + expect(result.B).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_reference', inputKey: 'ref-ghost' }) + ]) + ); + expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + }); + + it('never treats global variable references as unreachable', () => { + const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-var', [VARIABLE_NODE_ID, 'var1'])] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeB], + edges: [{ id: 'e-start-b', source: 'start', target: 'B', type: EDGE_TYPE }], + chatConfig: { variables: [{ key: 'var1' }] } as any, + referenceNodeIds: ['B'] + }); + + expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + }); + + it('reports a multi-select reference when any item becomes unreachable', () => { + const nodeB = makeOutputNode('B'); + const nodeC = makeOutputNode('C'); + const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { + inputs: [ + { + key: 'ref-list', + label: 'ref-list', + renderTypeList: [FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.arrayString, + value: [ + ['B', 'out'], + ['C', 'out'] + ] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeB, nodeC, nodeD], + // B 不可达,C 仍可达 + edges: [ + { id: 'e-start-c', source: 'start', target: 'C', type: EDGE_TYPE }, + { id: 'e-b-d', source: 'B', target: 'D', type: EDGE_TYPE }, + { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } + ], + referenceNodeIds: ['B', 'D'] + }); + + expect(result.D).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-list' }) + ]) + ); + }); + + it('reports unreachable VariableUpdate value references by row field', () => { + const nodeB = makeOutputNode('B'); + const nodeVU = makeNode('variable-update', FlowNodeTypeEnum.variableUpdate, { + inputs: [ + { + key: NodeInputKeyEnum.updateList, + label: 'update list', + value: [ + { + variable: [VARIABLE_NODE_ID, 'var1'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference, + value: [['B', 'out']] + } + ] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeB, nodeVU], + // B 不可达 + edges: [{ id: 'e-b-vu', source: 'B', target: 'variable-update', type: EDGE_TYPE }], + chatConfig: { + variables: [{ key: 'var1', valueType: WorkflowIOValueTypeEnum.string }] + } as any, + referenceNodeIds: ['B', 'variable-update'] + }); + + expect(result['variable-update']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'unreachable_reference', + inputKey: `${NodeInputKeyEnum.updateList}[0].value` + }) + ]) + ); + }); + + it('adds no reference issues for impact nodes without broken references', () => { + const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeB], + edges: [], + referenceNodeIds: ['B'] + }); + + expect( + result.B?.some((issue) => + ['invalid_reference', 'invalid_reference_type', 'unreachable_reference'].includes( + issue.code + ) + ) + ).toBeFalsy(); + }); + }); + it('preserves invalid single and multiple references when storing workflow data', () => { const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, { outputs: [ From 946f40bd526af15c1b726e8aa5ae3087bc161779 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Thu, 27 Aug 2026 14:25:21 +0800 Subject: [PATCH 04/18] feat(workflow): preserve reference snapshots --- .../global/core/workflow/migration/schema.ts | 2 +- .../workflow/template/system/ifElse/type.ts | 4 +- .../template/system/variableUpdate/type.ts | 8 +- packages/global/core/workflow/type/io.ts | 37 +- packages/global/core/workflow/type/node.ts | 2 + .../core/workflow/migration/schema.test.ts | 24 ++ .../Flow/nodes/NodeDatasetConcat.tsx | 14 +- .../Flow/nodes/NodeIfElse/ListItem.tsx | 41 +- .../Flow/nodes/NodePluginIO/PluginOutput.tsx | 13 +- .../NodeVariableUpdate/ValueRenderer.tsx | 15 +- .../NodeVariableUpdate/VariableSelector.tsx | 11 +- .../Flow/nodes/NodeVariableUpdate/index.tsx | 41 +- .../templates/DynamicInputs/index.tsx | 20 +- .../RenderInput/templates/Reference.tsx | 375 +++++++++++++++--- .../detail/WorkflowComponents/utils.test.ts | 175 ++++++++ 15 files changed, 655 insertions(+), 127 deletions(-) diff --git a/packages/global/core/workflow/migration/schema.ts b/packages/global/core/workflow/migration/schema.ts index c3481b01ba83..8f7a4fc00a71 100644 --- a/packages/global/core/workflow/migration/schema.ts +++ b/packages/global/core/workflow/migration/schema.ts @@ -6,7 +6,7 @@ import { NodeToolConfigTypeSchema, StoreNodeItemTypeSchema } from '../type/node' import { StoreEdgeItemTypeSchema } from '../type/edge'; /** - * 当前工作流输入。该类型不包含任何历史字段。 + * 当前工作流输入。引用快照是可选的历史展示字段,不参与运行时引用解析。 */ export const CanonicalFlowNodeInputItemSchema = FlowNodeInputItemTypeSchema; export type CanonicalFlowNodeInputItem = z.infer; diff --git a/packages/global/core/workflow/template/system/ifElse/type.ts b/packages/global/core/workflow/template/system/ifElse/type.ts index f09fd87397c2..928db7325440 100644 --- a/packages/global/core/workflow/template/system/ifElse/type.ts +++ b/packages/global/core/workflow/template/system/ifElse/type.ts @@ -1,11 +1,13 @@ -import type { ReferenceItemValueType } from '../../../type/io'; +import type { ReferenceItemValueType, WorkflowReferenceSnapshot } from '../../../type/io'; import type { VariableConditionEnum } from './constant'; export type IfElseConditionType = 'AND' | 'OR'; export type ConditionListItemType = { variable?: ReferenceItemValueType; + variableSnapshot?: WorkflowReferenceSnapshot; condition?: VariableConditionEnum; value?: string | ReferenceItemValueType; + valueSnapshot?: WorkflowReferenceSnapshot; valueType?: 'input' | 'reference'; }; export type IfElseListItemType = { diff --git a/packages/global/core/workflow/template/system/variableUpdate/type.ts b/packages/global/core/workflow/template/system/variableUpdate/type.ts index 396c339e4d5a..aad876b2d951 100644 --- a/packages/global/core/workflow/template/system/variableUpdate/type.ts +++ b/packages/global/core/workflow/template/system/variableUpdate/type.ts @@ -1,10 +1,16 @@ import type { FlowNodeInputTypeEnum } from '../../../node/constant'; -import type { ReferenceItemValueType, ReferenceValueType } from '../../..//type/io'; +import type { + ReferenceItemValueType, + ReferenceValueType, + WorkflowReferenceSnapshot +} from '../../..//type/io'; import type { WorkflowIOValueTypeEnum } from '../../../constants'; export type TUpdateListItem = { variable?: ReferenceItemValueType; + variableSnapshot?: WorkflowReferenceSnapshot; value?: ReferenceValueType; // input: ['',value], reference: [nodeId,outputId] + valueReferenceSnapshots?: WorkflowReferenceSnapshot[]; valueType?: WorkflowIOValueTypeEnum; renderType: FlowNodeInputTypeEnum.input | FlowNodeInputTypeEnum.reference; diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 5e6244999959..0256e014d79b 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -43,6 +43,28 @@ export const CustomFieldConfigTypeSchema = z.object({ }); export type CustomFieldConfigType = z.infer; +/* Reference */ +export const ReferenceItemValueTypeSchema = z.tuple([z.string(), z.string().optional()]); +export type ReferenceItemValueType = z.infer; +export const ReferenceArrayValueTypeSchema = z.array(ReferenceItemValueTypeSchema); +export type ReferenceArrayValueType = z.infer; +export const ReferenceValueTypeSchema = z.union([ + ReferenceItemValueTypeSchema, + ReferenceArrayValueTypeSchema +]); +export type ReferenceValueType = z.infer; + +/** + * 引用来源失效后的历史展示信息。reference 用于多选和嵌套配置按稳定身份匹配快照。 + * 状态和类型均由当前工作流实时计算,不在快照中持久化。 + */ +export const WorkflowReferenceSnapshotSchema = z.object({ + reference: ReferenceItemValueTypeSchema, + sourceLabel: z.string().optional(), + outputLabel: z.string().optional() +}); +export type WorkflowReferenceSnapshot = z.infer; + export const InputComponentPropsTypeSchema = z.object({ key: z.enum(NodeInputKeyEnum).or(z.string()).meta({ description: '变量键名,用于在提示词或工作流中引用该输入' @@ -271,6 +293,10 @@ export const FlowNodeInputItemTypeSchema = InputComponentPropsTypeSchema.extend( description: '节点输入当前值' }), + referenceSnapshots: z.array(WorkflowReferenceSnapshotSchema).optional().meta({ + description: '节点输入引用的历史展示快照' + }), + debugLabel: z.string().optional().meta({ description: '调试模式下展示的输入名称' }), @@ -379,17 +405,6 @@ export const FlowNodeOutputItemTypeSchema = z.object({ }); export type FlowNodeOutputItemType = z.infer; -/* Reference */ -export const ReferenceItemValueTypeSchema = z.tuple([z.string(), z.string().optional()]); -export type ReferenceItemValueType = z.infer; -export const ReferenceArrayValueTypeSchema = z.array(ReferenceItemValueTypeSchema); -export type ReferenceArrayValueType = z.infer; -export const ReferenceValueTypeSchema = z.union([ - ReferenceItemValueTypeSchema, - ReferenceArrayValueTypeSchema -]); -export type ReferenceValueType = z.infer; - /* http node */ export const HttpParamAndHeaderItemTypeSchema = z.object({ key: z.string(), diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index 1138fd109c8f..3f9c36a73c42 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -303,6 +303,8 @@ export type FlowNodeItemType = z.infer; // store node type export const StoreNodeItemTypeSchema = FlowNodeCommonTypeSchema.extend({ nodeId: z.string(), + /** 画布节点折叠状态,需要随工作流版本持久化。 */ + isFolded: BoolSchema.optional(), position: z .object({ x: NumSchema, diff --git a/packages/global/test/core/workflow/migration/schema.test.ts b/packages/global/test/core/workflow/migration/schema.test.ts index 4a313f1c5844..acbe65e3f619 100644 --- a/packages/global/test/core/workflow/migration/schema.test.ts +++ b/packages/global/test/core/workflow/migration/schema.test.ts @@ -40,6 +40,30 @@ describe('workflow input schema boundaries', () => { expect(input.selectedType).toBe(FlowNodeInputTypeEnum.reference); expect(input).not.toHaveProperty('selectedTypeIndex'); }); + + it('keeps reference snapshots in canonical data', () => { + const input = CanonicalFlowNodeInputItemSchema.parse({ + key: 'query', + label: 'Query', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['source-node', 'output-key'], + referenceSnapshots: [ + { + reference: ['source-node', 'output-key'], + sourceLabel: 'Source node', + outputLabel: 'Output label' + } + ] + }); + + expect(input.referenceSnapshots).toEqual([ + { + reference: ['source-node', 'output-key'], + sourceLabel: 'Source node', + outputLabel: 'Output label' + } + ]); + }); }); describe('workflow migration boundary', () => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx index 83cd587ed918..a07d307f2abc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx @@ -11,7 +11,8 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { getOneQuoteInputTemplate } from '@fastgpt/global/core/workflow/template/system/datasetConcat'; import { type FlowNodeInputItemType, - type ReferenceItemValueType + type ReferenceItemValueType, + type WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; import RenderOutput from './render/RenderOutput'; import IOTitle from '../components/IOTitle'; @@ -153,22 +154,21 @@ const VariableSelector = ({ const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: inputChildren.valueType }); const onSelect = useCallback( - (e?: ReferenceItemValueType) => { - if (!e) return; - + (e?: ReferenceItemValueType, snapshots?: WorkflowReferenceSnapshot[]) => { onChangeNode({ nodeId, type: 'replaceInput', key: inputChildren.key, value: { ...inputChildren, - value: e + value: e, + referenceSnapshots: e && snapshots?.length ? snapshots : undefined } }); }, @@ -207,6 +207,8 @@ const VariableSelector = ({ t('common:core.module.Dataset quote.select') )} list={referenceList} + liveList={liveReferenceList} + referenceSnapshots={inputChildren.referenceSnapshots} value={inputChildren.value} onSelect={onSelect} isArray={false} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx index 3481c22e1d8c..09008d6932bc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx @@ -6,7 +6,10 @@ import { import Container from '../../components/Container'; import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import MyIcon from '@fastgpt/web/components/common/Icon'; -import { type ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io'; +import { + type ReferenceItemValueType, + type WorkflowReferenceSnapshot +} from '@fastgpt/global/core/workflow/type/io'; import { useTranslation } from 'next-i18next'; import { ReferSelector, useReference } from '../render/RenderInput/templates/Reference'; import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; @@ -133,7 +136,8 @@ const ListItem = ({ { + variableSnapshot={item.variableSnapshot} + onSelect={(e, snapshots) => { onUpdateIfElseList( ifElseList.map((ifElse, index) => { if (index === conditionIndex) { @@ -144,6 +148,7 @@ const ListItem = ({ return { ...item, variable: e, + variableSnapshot: snapshots?.[0], condition: undefined }; } @@ -188,8 +193,9 @@ const ListItem = ({ valueType={item.valueType} condition={item.condition} variable={item.variable} + valueSnapshot={item.valueSnapshot} nodeId={nodeId} - updateValue={(value, valueType) => { + updateValue={(value, valueType, snapshot) => { onUpdateIfElseList( ifElseList.map((ifElse, index) => { return { @@ -201,6 +207,7 @@ const ListItem = ({ return { ...item, value, + valueSnapshot: snapshot, valueType }; } @@ -313,15 +320,17 @@ export default React.memo(ListItem); const VariableSelector = ({ nodeId, variable, + variableSnapshot, onSelect }: { nodeId: string; variable?: ReferenceItemValueType; - onSelect: (e?: ReferenceItemValueType) => void; + variableSnapshot?: WorkflowReferenceSnapshot; + onSelect: (e?: ReferenceItemValueType, snapshots?: WorkflowReferenceSnapshot[]) => void; }) => { const { t } = useTranslation(); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: WorkflowIOValueTypeEnum.any }); @@ -330,6 +339,8 @@ const VariableSelector = ({ void; + updateValue: ( + value: string | ReferenceItemValueType, + valueType: 'input' | 'reference', + snapshot?: WorkflowReferenceSnapshot + ) => void; nodeId: string; + valueSnapshot?: WorkflowReferenceSnapshot; }) => { const { t } = useTranslation(); const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); @@ -506,7 +523,7 @@ const ConditionValueInput = ({ return valueType; }, [condition, valueType, getArrayElementType]); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: referenceValueType }); @@ -563,9 +580,11 @@ const ConditionValueInput = ({ { - updateValue(e as ReferenceItemValueType, 'reference'); + onSelect={(e, snapshots) => { + updateValue(e as ReferenceItemValueType, 'reference', snapshots?.[0]); }} isArray={false} ButtonProps={{ @@ -576,7 +595,7 @@ const ConditionValueInput = ({ }} /> ); - }, [t, referenceList, isReference, value, updateValue]); + }, [t, referenceList, liveReferenceList, valueSnapshot, isReference, value, updateValue]); const isDisabled = condition === VariableConditionEnum.isEmpty || condition === VariableConditionEnum.isNotEmpty; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx index 7498ebbc34a9..4d89a6f72391 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx @@ -7,7 +7,8 @@ import { SmallAddIcon } from '@chakra-ui/icons'; import Container from '../../components/Container'; import { type FlowNodeInputItemType, - type ReferenceValueType + type ReferenceValueType, + type WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { useTranslation } from 'next-i18next'; @@ -109,22 +110,22 @@ function Reference({ const [editField, setEditField] = useState(); const onSelect = useCallback( - (e?: ReferenceValueType) => { - if (!e) return; + (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { onChangeNode({ nodeId, type: 'updateInput', key: input.key, value: { ...input, - value: e + value: e, + referenceSnapshots: e && snapshots?.length ? snapshots : undefined } }); }, [input, nodeId, onChangeNode] ); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: input.valueType }); @@ -206,6 +207,8 @@ function Reference({ ; value?: ReferenceValueType; + valueReferenceSnapshots?: WorkflowReferenceSnapshot[]; renderType: TUpdateListItem['renderType']; numberOperator?: TUpdateListItem['numberOperator']; booleanMode?: TUpdateListItem['booleanMode']; @@ -43,6 +47,7 @@ const ValueRenderer: React.FC = (props) => { stringInputType, stringFormParams = {}, value, + valueReferenceSnapshots, renderType, numberOperator, booleanMode, @@ -59,7 +64,13 @@ const ValueRenderer: React.FC = (props) => { nodeId={nodeId} variable={value} valueType={valueType} - onSelect={(v) => onChange({ value: v })} + referenceSnapshots={valueReferenceSnapshots} + onSelect={(v, snapshots) => + onChange({ + value: v, + valueReferenceSnapshots: snapshots?.length ? snapshots : undefined + }) + } /> ); } diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx index 42263d37ce9e..3886c9d82236 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx @@ -1,17 +1,21 @@ import React from 'react'; import { useTranslation } from 'next-i18next'; import type { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import type { ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; +import type { + ReferenceValueType, + WorkflowReferenceSnapshot +} from '@fastgpt/global/core/workflow/type/io'; import { ReferSelector, useReference } from '../render/RenderInput/templates/Reference'; type Props = { nodeId: string; variable?: ReferenceValueType; valueType?: WorkflowIOValueTypeEnum; - onSelect: (e?: ReferenceValueType) => void; + referenceSnapshots?: WorkflowReferenceSnapshot[]; + onSelect: (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => void; }; -const VariableSelector = ({ nodeId, variable, valueType, onSelect }: Props) => { +const VariableSelector = ({ nodeId, variable, valueType, referenceSnapshots, onSelect }: Props) => { const { t } = useTranslation(); const { referenceList, liveReferenceList } = useReference({ @@ -24,6 +28,7 @@ const VariableSelector = ({ nodeId, variable, valueType, onSelect }: Props) => { placeholder={t('common:select_reference_variable')} list={referenceList} liveList={liveReferenceList} + referenceSnapshots={referenceSnapshots} value={variable} onSelect={onSelect} isArray={valueType?.startsWith('array')} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx index b4290ec3d3a0..f683090d1a2c 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx @@ -117,21 +117,30 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => issue.inputKey === `${NodeInputKeyEnum.updateList}[${index}].variable` ); + const currentRefData = getRefData({ + variable: updateItem.variable, + getNodeById, + chatConfig: appDetail.chatConfig + }); + // 目标变量暂时失效或类型变化时,继续使用保存时的类型渲染旧值;重新选择后才刷新。 + const valueType = updateItem.valueType ?? currentRefData.valueType; + // 根据目标变量推断 string 分支的 inputType 与表单参数(select options 等) const { inputType, formParams = {} } = (() => { const value = updateItem.variable; if (!value) { - return { inputType: InputTypeEnum.input }; + return { inputType: valueTypeToInputType(updateItem.valueType) }; } if (value[0] === VARIABLE_NODE_ID) { const variableList = appDetail.chatConfig.variables || []; const variable = variableList.find((item) => item.key === value[1]); if (variable) { // 文件类型在变量更新节点中使用文本框(无运行时上下文) + const targetValueType = updateItem.valueType ?? variable.valueType; const it = variable.type === VariableInputEnum.file ? InputTypeEnum.textarea - : variableInputTypeToInputType(variable.type, variable.valueType); + : variableInputTypeToInputType(variable.type, targetValueType); return { inputType: it, formParams: { @@ -155,17 +164,15 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => } } else if (value[0] && value[1]) { const output = getNodeById(value[0])?.outputs.find((o) => o.id === value[1]); - if (output) return { inputType: valueTypeToInputType(output.valueType) }; + if (output) { + return { + inputType: valueTypeToInputType(updateItem.valueType ?? output.valueType) + }; + } } - return { inputType: InputTypeEnum.input }; + return { inputType: valueTypeToInputType(updateItem.valueType) }; })(); - const { valueType } = getRefData({ - variable: updateItem.variable, - getNodeById, - chatConfig: appDetail.chatConfig - }); - const applyPatch = (patch: Partial) => { onUpdateList( updateList.map((update, i) => (i === index ? { ...update, ...patch } : update)) @@ -188,7 +195,10 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => { + referenceSnapshots={ + updateItem.variableSnapshot ? [updateItem.variableSnapshot] : undefined + } + onSelect={(value, snapshots) => { const newValueType = getRefData({ variable: value as ReferenceItemValueType, getNodeById, @@ -196,6 +206,8 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => }).valueType; applyPatch({ variable: value as ReferenceItemValueType, + variableSnapshot: snapshots?.[0], + valueReferenceSnapshots: undefined, ...getDefaultsForValueType(newValueType) }); }} @@ -223,7 +235,11 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => e === FlowNodeInputTypeEnum.reference ? FlowNodeInputTypeEnum.reference : FlowNodeInputTypeEnum.input; - applyPatch({ renderType: nt, value: undefined }); + applyPatch({ + renderType: nt, + value: undefined, + valueReferenceSnapshots: undefined + }); }} /> @@ -236,6 +252,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => stringInputType={inputType} stringFormParams={formParams} value={updateItem.value} + valueReferenceSnapshots={updateItem.valueReferenceSnapshots} renderType={updateItem.renderType} numberOperator={updateItem.numberOperator} booleanMode={updateItem.booleanMode} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx index ebaf0ff4b016..e776e8949e2a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx @@ -5,7 +5,8 @@ import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { type FlowNodeInputItemType, - type ReferenceValueType + type ReferenceValueType, + type WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; import { useContextSelector } from 'use-context-selector'; import { getInputComponentProps } from '@/web/core/workflow/utils'; @@ -135,7 +136,7 @@ const Reference = ({ const [tempLabel, setTempLabel] = useState(''); const [isEditing, setIsEditing] = useState(false); - const { referenceList } = useReference({ + const { referenceList, liveReferenceList } = useReference({ nodeId, valueType: WorkflowIOValueTypeEnum.any, // Container nodes (loopRun) need to reference outputs from their sub-workflow. @@ -187,12 +188,12 @@ const Reference = ({ [existsKeys, toast, t, isEmptyItem, item, onChangeNode, nodeId, inputChildren] ); const onSelectReference = useCallback( - (e?: ReferenceValueType) => { - if (!e) return; - - const referenceItem = referenceList - .find((item) => item.value === e[0]) - ?.children.find((item) => item.value === e[1]); + (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { + const referenceItem = e + ? referenceList + .find((item) => item.value === e[0]) + ?.children.find((item) => item.value === e[1]) + : undefined; onChangeNode({ nodeId, @@ -201,6 +202,7 @@ const Reference = ({ value: { ...inputChildren, value: e, + referenceSnapshots: e && snapshots?.length ? snapshots : undefined, valueType: referenceItem?.valueType || WorkflowIOValueTypeEnum.any } }); @@ -233,6 +235,8 @@ const Reference = ({ FlowNodeItemType | undefined; }) => { - if (!value) return false; + if (!isConfiguredReferenceItem(value)) return false; const [refNodeId, refOutputId] = value; - if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return false; if (!refNodeId || !refOutputId || refNodeId === VARIABLE_NODE_ID) return false; const sourceNode = getNodeById(refNodeId); @@ -72,25 +73,179 @@ const referenceSourceStillExists = ({ type ReferenceSelectList = { label: string | React.ReactNode; value: string; + sourceLabel?: string; children: { label: string; value: string; + outputLabel?: string; valueType?: WorkflowIOValueTypeEnum; }[]; }[]; +const isConfiguredReferenceItem = (value: unknown): value is [string, string] => + Array.isArray(value) && + typeof value[0] === 'string' && + value[0].length > 0 && + typeof value[1] === 'string' && + value[1].length > 0; + +const isSameReference = (left?: ReferenceItemValueType, right?: ReferenceItemValueType): boolean => + isConfiguredReferenceItem(left) && + isConfiguredReferenceItem(right) && + left[0] === right[0] && + left[1] === right[1]; + +const getReferenceValueTitle = (value: unknown) => { + if (Array.isArray(value)) { + return value + .filter((item) => item !== undefined && item !== null) + .map((item) => String(item)) + .join('.'); + } + return typeof value === 'string' ? value : undefined; +}; + +const getReferenceOutputKey = (value: unknown) => { + if (Array.isArray(value) && typeof value[1] === 'string') return value[1]; + return typeof value === 'string' ? value : undefined; +}; + +const getReferenceSnapshotFromList = ({ + value, + list +}: { + value: ReferenceItemValueType; + list: ReferenceSelectList; +}): WorkflowReferenceSnapshot | undefined => { + if (!isConfiguredReferenceItem(value)) return; + + const source = list.find((item) => item.value === value[0]); + const output = source?.children.find((item) => item.value === value[1]); + if (!source || !output) return; + + return { + reference: value, + sourceLabel: source.sourceLabel, + outputLabel: output.outputLabel + }; +}; + +const getReferenceSnapshotFromSource = ({ + value, + getNodeById +}: { + value: ReferenceItemValueType; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; +}): WorkflowReferenceSnapshot | undefined => { + if (!isConfiguredReferenceItem(value) || value[0] === VARIABLE_NODE_ID) return; + + const sourceNode = getNodeById(value[0]); + const output = sourceNode?.outputs.find((item) => item.id === value[1]); + if (!sourceNode || !output) return; + + return { + reference: value, + sourceLabel: sourceNode.name, + outputLabel: output.label + }; +}; + +const getReferenceSnapshot = ({ + value, + list, + liveList, + referenceSnapshots, + getNodeById +}: { + value?: ReferenceItemValueType; + list: ReferenceSelectList; + liveList: ReferenceSelectList; + referenceSnapshots?: WorkflowReferenceSnapshot[]; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; +}) => { + if (!isConfiguredReferenceItem(value)) return; + + return ( + getReferenceSnapshotFromList({ value, list }) ?? + getReferenceSnapshotFromList({ value, list: liveList }) ?? + getReferenceSnapshotFromSource({ value, getNodeById }) ?? + referenceSnapshots?.find((item) => isSameReference(item.reference, value)) + ); +}; + +const getReferenceItems = (value?: ReferenceValueType): ReferenceItemValueType[] => { + if (isConfiguredReferenceItem(value)) return [value]; + if (!Array.isArray(value)) return []; + + return value.filter((item): item is ReferenceItemValueType => isConfiguredReferenceItem(item)); +}; + +const formatReferenceSnapshots = ({ + value, + list, + liveList, + referenceSnapshots, + getNodeById +}: { + value?: ReferenceValueType; + list: ReferenceSelectList; + liveList: ReferenceSelectList; + referenceSnapshots?: WorkflowReferenceSnapshot[]; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; +}) => + getReferenceItems(value) + .map((item) => + getReferenceSnapshot({ + value: item, + list, + liveList, + referenceSnapshots, + getNodeById + }) + ) + .filter((item): item is WorkflowReferenceSnapshot => !!item); + +const ReferenceSnapshotLabel = ({ + snapshot, + fallback, + t +}: { + snapshot?: WorkflowReferenceSnapshot; + fallback?: string; + t: ReturnType['t']; +}) => { + const sourceLabel = snapshot?.sourceLabel; + const outputLabel = snapshot?.outputLabel ? t(snapshot.outputLabel as any) : fallback; + + if (sourceLabel && outputLabel) { + return ( + <> + {sourceLabel} + + {outputLabel} + + ); + } + + return <>{outputLabel || sourceLabel || fallback}; +}; + type CommonSelectProps = { placeholder?: string; list: ReferenceSelectList; // 不做类型过滤的可选输出列表,仅用于区分「来源已删除」和「类型不匹配」两种失效原因 liveList?: ReferenceSelectList; + referenceSnapshots?: WorkflowReferenceSnapshot[]; popDirection?: 'top' | 'bottom'; ButtonProps?: ButtonProps; }; type SelectProps = CommonSelectProps & { isArray?: T; value?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType; - onSelect: (val?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType) => void; + onSelect: ( + val?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType, + snapshots?: WorkflowReferenceSnapshot[] + ) => void; }; export const useReference = ({ @@ -140,6 +295,7 @@ export const useReference = ({ outputs.map((output) => ({ label: t(output.label as any), value: output.id, + outputLabel: output.label, valueType: output.valueType })); @@ -153,6 +309,7 @@ export const useReference = ({ referenceList.push({ label, value: node.nodeId, + sourceLabel: node.name, children: toChildren(selectableOutputs) }); } @@ -166,6 +323,7 @@ export const useReference = ({ liveReferenceList.push({ label, value: node.nodeId, + sourceLabel: node.name, children: toChildren(liveOutputs) }); } @@ -198,14 +356,15 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { const isArray = item.valueType?.includes('array') ?? false; const onSelect = useCallback( - (e?: ReferenceValueType) => { + (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { onChangeNode({ nodeId, type: 'updateInput', key: item.key, value: { ...item, - value: e + value: e, + referenceSnapshots: snapshots?.length ? snapshots : undefined } }); }, @@ -228,6 +387,7 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { placeholder={t(item.referencePlaceholder as any) || t('common:select_reference_variable')} list={referenceList} liveList={liveReferenceList} + referenceSnapshots={item.referenceSnapshots} value={item.value} onSelect={onSelect} popDirection={popDirection} @@ -243,6 +403,7 @@ const SingleReferenceSelector = ({ value, list = [], liveList = [], + referenceSnapshots, onSelect, popDirection, ButtonProps @@ -250,22 +411,19 @@ const SingleReferenceSelector = ({ const { t } = useSafeTranslation(); const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); - const getSelectValue = useCallback( - (value: ReferenceValueType, searchList: ReferenceSelectList) => { - if (!value) return []; + const getSelectValue = useCallback((value: unknown, searchList: ReferenceSelectList) => { + if (!isConfiguredReferenceItem(value)) return []; - const firstColumn = searchList.find((item) => item.value === value[0]); - if (!firstColumn) { - return []; - } - const secondColumn = firstColumn.children.find((item) => item.value === value[1]); - if (!secondColumn) { - return []; - } - return [firstColumn.label, secondColumn.label]; - }, - [] - ); + const firstColumn = searchList.find((item) => item.value === value[0]); + if (!firstColumn) { + return []; + } + const secondColumn = firstColumn.children.find((item) => item.value === value[1]); + if (!secondColumn) { + return []; + } + return [firstColumn.label, secondColumn.label]; + }, []); // Adapt array type from old version useEffect(() => { @@ -296,8 +454,24 @@ const SingleReferenceSelector = ({ isInvalidReference && !isTypeMismatch && referenceSourceStillExists({ value: selectorVal, getNodeById }); - - return ( + const referenceOutputKey = getReferenceOutputKey(selectorVal); + const referenceTitle = getReferenceValueTitle(selectorVal); + const invalidReason = isTypeMismatch + ? t('common:core.workflow.check.reference_type_mismatch') + : isSourceUnreachable + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted'); + const invalidSnapshot = isInvalidReference + ? getReferenceSnapshot({ + value: selectorVal, + list, + liveList, + referenceSnapshots, + getNodeById + }) + : undefined; + + const selector = ( {outputName} + ) : isInvalidReference ? ( + + + + + ) : ( - - {isInvalidReference - ? isTypeMismatch - ? t('common:core.workflow.check.reference_type_mismatch') - : isSourceUnreachable - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted') - : placeholder} + + {placeholder} ) } value={selectorVal} list={list} - onSelect={onSelect as any} + onSelect={(nextValue) => { + const snapshots = formatReferenceSnapshots({ + value: nextValue as ReferenceValueType | undefined, + list, + liveList, + referenceSnapshots, + getNodeById + }); + onSelect( + nextValue as ReferenceItemValueType | undefined, + snapshots.length ? snapshots : undefined + ); + }} popDirection={popDirection} - ButtonProps={ButtonProps} + ButtonProps={ + isInvalidReference + ? { + ...ButtonProps, + borderColor: 'red.500', + color: 'red.500', + _hover: { borderColor: 'red.400' } + } + : ButtonProps + } /> ); + + return isInvalidReference ? ( + + {selector} + + ) : ( + selector + ); }, [ ButtonProps, getSelectValue, @@ -338,6 +541,7 @@ const SingleReferenceSelector = ({ onSelect, placeholder, popDirection, + referenceSnapshots, t, value ]); @@ -349,28 +553,26 @@ const MultipleReferenceSelector = ({ value, list = [], liveList = [], + referenceSnapshots, onSelect, popDirection }: SelectProps) => { const { t } = useSafeTranslation(); const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); - const getSelectValue = useCallback( - (value: ReferenceValueType, searchList: ReferenceSelectList) => { - if (!value) return []; + const getSelectValue = useCallback((value: unknown, searchList: ReferenceSelectList) => { + if (!isConfiguredReferenceItem(value)) return []; - const firstColumn = searchList.find((item) => item.value === value[0]); - if (!firstColumn) { - return []; - } - const secondColumn = firstColumn.children.find((item) => item.value === value[1]); - if (!secondColumn) { - return []; - } - return [firstColumn.label, secondColumn.label]; - }, - [] - ); + const firstColumn = searchList.find((item) => item.value === value[0]); + if (!firstColumn) { + return []; + } + const secondColumn = firstColumn.children.find((item) => item.value === value[1]); + if (!secondColumn) { + return []; + } + return [firstColumn.label, secondColumn.label]; + }, []); // Get valid item and remove invalid item const formatList = useMemo(() => { @@ -386,13 +588,27 @@ const MultipleReferenceSelector = ({ }); }, [getSelectValue, list, value]); + const handleSelect = useCallback( + (nextValue?: ReferenceArrayValueType) => { + const snapshots = formatReferenceSnapshots({ + value: nextValue, + list, + liveList, + referenceSnapshots, + getNodeById + }); + onSelect(nextValue, snapshots.length ? snapshots : undefined); + }, + [getNodeById, list, liveList, onSelect, referenceSnapshots] + ); + useEffect(() => { // Adapt array type from old version if (Array.isArray(value) && typeof value[0] === 'string') { // @ts-ignore - onSelect([value]); + handleSelect([value]); } - }, [formatList, onSelect, value]); + }, [formatList, handleSelect, value]); const ArraySelector = useMemo(() => { return ( @@ -425,7 +641,23 @@ const MultipleReferenceSelector = ({ isInvalidReference && !isTypeMismatch && referenceSourceStillExists({ value: rawValue, getNodeById }); - return ( + const referenceOutputKey = getReferenceOutputKey(rawValue); + const referenceTitle = getReferenceValueTitle(rawValue); + const invalidReason = isTypeMismatch + ? t('common:core.workflow.check.reference_type_mismatch') + : isSourceUnreachable + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted'); + const invalidSnapshot = isInvalidReference + ? getReferenceSnapshot({ + value: rawValue, + list, + liveList, + referenceSnapshots, + getNodeById + }) + : undefined; + const row = ( - + {isValidReference ? ( <> {nodeName} @@ -449,13 +686,11 @@ const MultipleReferenceSelector = ({ {outputName} ) : isInvalidReference ? ( - - {isTypeMismatch - ? t('common:core.workflow.check.reference_type_mismatch') - : isSourceUnreachable - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted')} - + ) : ( {placeholder} )} @@ -473,11 +708,18 @@ const MultipleReferenceSelector = ({ }} onClick={(e) => { e.stopPropagation(); - onSelect(value?.filter((_, i) => i !== index)); + handleSelect(value?.filter((_, i) => i !== index)); }} /> ); + return isInvalidReference ? ( + + {row} + + ) : ( + row + ); })} ) : ( @@ -489,7 +731,7 @@ const MultipleReferenceSelector = ({ value={value as any} list={list} onSelect={(e) => { - onSelect(e as any); + handleSelect(e as ReferenceArrayValueType); }} popDirection={popDirection} /> @@ -500,9 +742,10 @@ const MultipleReferenceSelector = ({ getNodeById, list, liveList, - onSelect, + handleSelect, placeholder, popDirection, + referenceSnapshots, t, value ]); diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index fbbb919dc8a0..6503ac93008d 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -12,6 +12,9 @@ import { import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { AppDetailType } from '@fastgpt/global/core/app/type'; +import { CanonicalWorkflowDataSchema } from '@fastgpt/global/core/workflow/migration/schema'; +import { PublishAppBodySchema } from '@fastgpt/global/openapi/core/app/version/api'; +import { storeNode2FlowNode } from '@/web/core/workflow/utils'; describe('WorkflowComponents utils', () => { describe('uiWorkflow2StoreWorkflow', () => { @@ -153,6 +156,140 @@ describe('WorkflowComponents utils', () => { ]); }); + it('should validate and restore folded Code tool nodes', () => { + const nodes = [ + { + data: { + nodeId: 'agent-node', + name: 'Agent', + flowNodeType: FlowNodeTypeEnum.toolCall, + inputs: [], + outputs: [] + }, + position: { x: 0, y: 0 } + }, + { + data: { + nodeId: 'code-node', + name: 'Code', + flowNodeType: FlowNodeTypeEnum.code, + inputs: [ + { + key: 'data1', + label: 'data1', + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.input, + canEdit: true + } + ], + outputs: [], + isFolded: true + }, + position: { x: 300, y: 0 } + } + ]; + const edges = [ + { + source: 'agent-node', + target: 'code-node', + sourceHandle: 'tool-output', + targetHandle: 'selectedTools' + } + ]; + + const stored = uiWorkflow2StoreWorkflow({ nodes, edges }); + const workflow = CanonicalWorkflowDataSchema.parse({ + ...stored, + chatConfig: {} + }); + const published = PublishAppBodySchema.parse({ + ...stored, + chatConfig: {} + }); + + expect(workflow.nodes[1]?.isFolded).toBe(true); + expect(published.nodes?.[1]?.isFolded).toBe(true); + + const restored = storeNode2FlowNode({ + item: workflow.nodes[1], + t: ((key: string) => key) as any + }); + expect(restored.data.isFolded).toBe(true); + }); + + it('should preserve reference snapshots through canonical validation and restore', () => { + const nodes = [ + { + data: { + nodeId: 'source-node', + name: 'Source node', + flowNodeType: FlowNodeTypeEnum.chatNode, + inputs: [], + outputs: [ + { + id: 'output-key', + key: 'output-key', + label: 'Output label', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }, + position: { x: 0, y: 0 } + }, + { + data: { + nodeId: 'target-node', + name: 'Target node', + flowNodeType: FlowNodeTypeEnum.answerNode, + inputs: [ + { + key: 'text', + label: 'Text', + value: ['source-node', 'output-key'], + referenceSnapshots: [ + { + reference: ['source-node', 'output-key'], + sourceLabel: 'Source node', + outputLabel: 'Output label' + } + ], + renderTypeList: [FlowNodeInputTypeEnum.reference] + } + ], + outputs: [] + }, + position: { x: 300, y: 0 } + } + ]; + + const stored = uiWorkflow2StoreWorkflow({ nodes, edges: [] }); + const workflow = CanonicalWorkflowDataSchema.parse({ + ...stored, + chatConfig: {} + }); + const published = PublishAppBodySchema.parse({ + ...stored, + chatConfig: {} + }); + + expect(workflow.nodes[1]?.inputs[0]?.referenceSnapshots).toEqual( + stored.nodes[1]?.inputs[0]?.referenceSnapshots + ); + expect(published.nodes?.[1]?.inputs[0]?.referenceSnapshots).toEqual( + stored.nodes[1]?.inputs[0]?.referenceSnapshots + ); + + const restored = storeNode2FlowNode({ + item: workflow.nodes[1], + t: ((key: string) => key) as any + }); + expect(restored.data.inputs[0]?.referenceSnapshots).toEqual( + stored.nodes[1]?.inputs[0]?.referenceSnapshots + ); + }); + it('should filter malformed edges that cannot be restored', () => { const nodes = [ { @@ -332,6 +469,44 @@ describe('WorkflowComponents utils', () => { expect(result.nodes[1].inputs[0].value).toEqual(referenceValue); }); + it('should preserve reference snapshots when saving workflow', () => { + const referenceSnapshots = [ + { + reference: ['sourceNode', 'text'], + sourceLabel: 'Source before deletion', + outputLabel: 'Text before deletion' + } + ]; + const nodes = [ + { + data: { + nodeId: 'targetNode', + name: 'Target', + intro: '', + avatar: '', + flowNodeType: FlowNodeTypeEnum.chatNode, + showStatus: true, + inputs: [ + { + key: 'referenceInput', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + valueType: WorkflowIOValueTypeEnum.string, + value: ['sourceNode', 'text'], + referenceSnapshots + } + ], + outputs: [] + }, + position: { x: 0, y: 0 } + } + ]; + + const result = uiWorkflow2StoreWorkflow({ nodes, edges: [] }); + + expect(result.nodes[0].inputs[0].referenceSnapshots).toEqual(referenceSnapshots); + }); + it('should preserve unselectable values from reference inputs', () => { const nodes = [ { From 52d063e9c1ce6a850c000eee61e956ec5eb68acb Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 31 Aug 2026 11:29:39 +0800 Subject: [PATCH 05/18] feat(workflow): unify variable references --- .../core/workflow/migration/legacy/schema.ts | 5 +- .../workflow/migration/legacy/workflow.ts | 142 +++++- packages/global/core/workflow/type/io.ts | 2 +- .../core/workflow/migration/schema.test.ts | 147 +++++- .../Flow/nodes/NodeDatasetConcat.tsx | 6 +- .../Flow/nodes/NodeIfElse/ListItem.tsx | 26 +- .../Flow/nodes/NodePluginIO/PluginOutput.tsx | 6 +- .../NodeVariableUpdate/VariableSelector.tsx | 6 +- .../templates/DynamicInputs/index.tsx | 6 +- .../RenderInput/templates/Reference.tsx | 218 +++------ .../context/workflowActionsContext.tsx | 76 +-- .../app/detail/WorkflowComponents/utils.ts | 23 +- projects/app/src/web/core/workflow/utils.ts | 309 ++++++++---- .../src/web/core/workflow/workflowCheck.ts | 460 +++++------------- .../test/web/core/app/workflow/utils.test.ts | 220 ++------- 15 files changed, 784 insertions(+), 868 deletions(-) diff --git a/packages/global/core/workflow/migration/legacy/schema.ts b/packages/global/core/workflow/migration/legacy/schema.ts index 9a88280321dc..03fb7795df4c 100644 --- a/packages/global/core/workflow/migration/legacy/schema.ts +++ b/packages/global/core/workflow/migration/legacy/schema.ts @@ -14,9 +14,12 @@ const LegacyAppChatConfigSchema = AppChatConfigTypeSchema.omit({ questionGuide: /** 历史工作流输入。旧索引只允许在外部数据迁移阶段出现。 */ export const LegacyFlowNodeInputItemSchema = FlowNodeInputItemTypeSchema.omit({ - renderTypeList: true + renderTypeList: true, + referenceSnapshots: true }).extend({ renderTypeList: z.array(z.enum(FlowNodeInputTypeEnum)).optional(), + // snapshot/reference shape is cleaned after legacy structure migration. + referenceSnapshots: z.unknown().optional(), isToolParam: BoolSchema.optional().meta({ description: '历史工具输入默认由 Agent 生成标记', deprecated: true diff --git a/packages/global/core/workflow/migration/legacy/workflow.ts b/packages/global/core/workflow/migration/legacy/workflow.ts index df66070dfc74..ec096c1eea5e 100644 --- a/packages/global/core/workflow/migration/legacy/workflow.ts +++ b/packages/global/core/workflow/migration/legacy/workflow.ts @@ -16,8 +16,148 @@ import { isValidReferenceValueFormat, nodeInputIsReference } from '../../utils'; +import type { WorkflowReferenceSnapshot } from '../../type/io'; import type { NodeToolConfigType } from '../../type/node'; +const isReferenceItem = (value: unknown): value is [string, string] => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + typeof value[1] === 'string' && + value[0].length > 0 && + value[1].length > 0; + +const isEmptyReferenceValue = (value: unknown) => + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && + (value.length === 0 || + (value.length === 2 && + ((value[0] === '' && value[1] === '') || + (value[0] === undefined && value[1] === undefined))))); + +const cleanReferenceValue = (value: unknown, isArray: boolean) => { + if (isArray) { + if (isReferenceItem(value)) return [value]; + if (!Array.isArray(value)) return isEmptyReferenceValue(value) ? value : []; + return value.filter(isReferenceItem); + } + + if (isReferenceItem(value)) return value; + if (Array.isArray(value) && value.length === 1 && isReferenceItem(value[0])) { + return value[0]; + } + return isEmptyReferenceValue(value) ? value : undefined; +}; + +const cleanReferenceSnapshots = (value: unknown) => { + if (!Array.isArray(value)) return undefined; + + return value.filter((item): item is WorkflowReferenceSnapshot => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return false; + const snapshot = item as Record; + return ( + isReferenceItem(snapshot.reference) && + (snapshot.sourceLabel === undefined || typeof snapshot.sourceLabel === 'string') && + (snapshot.outputLabel === undefined || typeof snapshot.outputLabel === 'string') + ); + }); +}; + +const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { + const cleanInput = (input: CanonicalWorkflowData['nodes'][number]['inputs'][number]) => { + const nextInput = { ...input }; + if (nodeInputIsReference(input)) { + const value = cleanReferenceValue(input.value, input.valueType?.startsWith('array') === true); + if (value === undefined) delete nextInput.value; + else nextInput.value = value; + } + + if (input.referenceSnapshots !== undefined) { + const snapshots = cleanReferenceSnapshots(input.referenceSnapshots); + if (snapshots?.length) nextInput.referenceSnapshots = snapshots; + else delete nextInput.referenceSnapshots; + } + + if ( + node.flowNodeType === FlowNodeTypeEnum.ifElseNode && + input.key === NodeInputKeyEnum.ifElseList + ) { + nextInput.value = Array.isArray(input.value) + ? input.value.map((branch) => { + if (!branch || typeof branch !== 'object' || Array.isArray(branch)) return branch; + const nextBranch = { ...(branch as Record) }; + nextBranch.list = Array.isArray(nextBranch.list) + ? nextBranch.list.map((condition) => { + if (!condition || typeof condition !== 'object' || Array.isArray(condition)) { + return condition; + } + const nextCondition = { ...(condition as Record) }; + const variable = cleanReferenceValue(nextCondition.variable, false); + if (variable === undefined) delete nextCondition.variable; + else nextCondition.variable = variable; + if (nextCondition.valueType === 'reference') { + const value = cleanReferenceValue(nextCondition.value, false); + if (value === undefined) delete nextCondition.value; + else nextCondition.value = value; + } + const variableSnapshot = cleanReferenceSnapshots( + nextCondition.variableSnapshot ? [nextCondition.variableSnapshot] : undefined + )?.[0]; + if (variableSnapshot) nextCondition.variableSnapshot = variableSnapshot; + else delete nextCondition.variableSnapshot; + const valueSnapshot = cleanReferenceSnapshots( + nextCondition.valueSnapshot ? [nextCondition.valueSnapshot] : undefined + )?.[0]; + if (valueSnapshot) nextCondition.valueSnapshot = valueSnapshot; + else delete nextCondition.valueSnapshot; + return nextCondition; + }) + : nextBranch.list; + return nextBranch; + }) + : input.value; + } + + if ( + node.flowNodeType === FlowNodeTypeEnum.variableUpdate && + input.key === NodeInputKeyEnum.updateList + ) { + nextInput.value = Array.isArray(input.value) + ? input.value.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const nextItem = { ...(item as Record) }; + const variable = cleanReferenceValue(nextItem.variable, false); + if (variable === undefined) delete nextItem.variable; + else nextItem.variable = variable; + if (nextItem.renderType === FlowNodeInputTypeEnum.reference) { + const value = cleanReferenceValue(nextItem.value, true); + if (value === undefined) delete nextItem.value; + else nextItem.value = value; + } + const variableSnapshot = cleanReferenceSnapshots( + nextItem.variableSnapshot ? [nextItem.variableSnapshot] : undefined + )?.[0]; + if (variableSnapshot) nextItem.variableSnapshot = variableSnapshot; + else delete nextItem.variableSnapshot; + const valueSnapshots = cleanReferenceSnapshots(nextItem.valueReferenceSnapshots); + if (valueSnapshots?.length) nextItem.valueReferenceSnapshots = valueSnapshots; + else delete nextItem.valueReferenceSnapshots; + return nextItem; + }) + : input.value; + } + + return nextInput; + }; + + return { + ...node, + inputs: node.inputs.map(cleanInput) + }; +}; + // 这些旧插件没有持久化 catchError,但当前运行时需要显式开启错误分支。 const legacyCatchErrorPluginIds = new Set([ 'systemTool-dalle3', @@ -193,7 +333,7 @@ const migrateLegacyCanvasStructure = (workflow: CanonicalWorkflowData): Canonica }); }); - return { ...workflow, nodes, edges }; + return { ...workflow, nodes: nodes.map(cleanReferenceFields), edges }; }; /** * 将无需资源解析的历史结构收敛为当前 workflow 结构。 diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 0256e014d79b..76fefc2d5d99 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -44,7 +44,7 @@ export const CustomFieldConfigTypeSchema = z.object({ export type CustomFieldConfigType = z.infer; /* Reference */ -export const ReferenceItemValueTypeSchema = z.tuple([z.string(), z.string().optional()]); +export const ReferenceItemValueTypeSchema = z.tuple([z.string(), z.string()]); export type ReferenceItemValueType = z.infer; export const ReferenceArrayValueTypeSchema = z.array(ReferenceItemValueTypeSchema); export type ReferenceArrayValueType = z.infer; diff --git a/packages/global/test/core/workflow/migration/schema.test.ts b/packages/global/test/core/workflow/migration/schema.test.ts index acbe65e3f619..2f12bc202bd1 100644 --- a/packages/global/test/core/workflow/migration/schema.test.ts +++ b/packages/global/test/core/workflow/migration/schema.test.ts @@ -5,7 +5,7 @@ import { migrateWorkflowToCurrent } from '@fastgpt/global/core/workflow/migration'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { AppChatConfigTypeSchema } from '@fastgpt/global/core/app/type'; const inputWithLegacyIndex = { @@ -64,6 +64,151 @@ describe('workflow input schema boundaries', () => { } ]); }); + + it('cleans malformed references at the migration boundary and preserves dead tuples', async () => { + const input = { + nodes: [ + { + nodeId: 'consumer', + flowNodeType: 'answerNode', + name: 'Consumer', + isFolded: true, + inputs: [ + { + key: 'single', + label: 'Single', + renderTypeList: [FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string, + value: ['deleted-node', 'deleted-output'], + referenceSnapshots: [ + { + reference: ['deleted-node', 'deleted-output'], + sourceLabel: 'Deleted node', + outputLabel: 'Deleted output' + }, + { reference: ['malformed'], sourceLabel: 1 }, + { reference: ['deleted-node', undefined] } + ] + }, + { + key: 'malformed-single', + label: 'Malformed single', + renderTypeList: [FlowNodeInputTypeEnum.reference], + value: ['deleted-node', undefined] + }, + { + key: 'multiple', + label: 'Multiple', + renderTypeList: [FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.arrayString, + value: [ + ['kept-node', 'kept-output'], + ['deleted-node'], + ['deleted-node', undefined], + ['', 'output'] + ] + } + ], + outputs: [] + }, + { + nodeId: 'if-else', + flowNodeType: 'ifElseNode', + name: 'If Else', + inputs: [ + { + key: NodeInputKeyEnum.ifElseList, + label: 'If Else', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: [ + { + condition: 'AND', + list: [ + { + variable: ['deleted-node', 'variable'], + variableSnapshot: { + reference: ['deleted-node', 'variable'], + sourceLabel: 'Deleted node' + }, + condition: 'isEqual', + valueType: 'reference', + value: ['deleted-node', 'value'], + valueSnapshot: { reference: ['deleted-node', undefined] } + } + ] + } + ] + } + ], + outputs: [] + }, + { + nodeId: 'variable-update', + flowNodeType: 'variableUpdate', + name: 'Variable Update', + inputs: [ + { + key: NodeInputKeyEnum.updateList, + label: 'Update list', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: [ + { + variable: ['deleted-node', 'target'], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference, + value: [ + ['deleted-node', 'value'], + ['deleted-node', undefined] + ], + valueReferenceSnapshots: [ + { reference: ['deleted-node', 'value'], sourceLabel: 'Deleted node' }, + { reference: ['deleted-node', undefined] } + ] + } + ] + } + ], + outputs: [] + } + ], + edges: [] + } as any; + + const result = await migrateWorkflowToCurrent(input); + const consumer = result.nodes.find((node) => node.nodeId === 'consumer')!; + const ifElse = result.nodes.find((node) => node.nodeId === 'if-else')!; + const variableUpdate = result.nodes.find((node) => node.nodeId === 'variable-update')!; + + expect(consumer.isFolded).toBe(true); + expect(consumer.inputs[0]).toMatchObject({ + value: ['deleted-node', 'deleted-output'], + referenceSnapshots: [ + { + reference: ['deleted-node', 'deleted-output'], + sourceLabel: 'Deleted node', + outputLabel: 'Deleted output' + } + ] + }); + expect(consumer.inputs[1]).not.toHaveProperty('value'); + expect(consumer.inputs[2].value).toEqual([['kept-node', 'kept-output']]); + + const condition = (ifElse.inputs[0].value as any)[0].list[0]; + expect(condition.variable).toEqual(['deleted-node', 'variable']); + expect(condition.variableSnapshot).toMatchObject({ + reference: ['deleted-node', 'variable'] + }); + expect(condition.value).toEqual(['deleted-node', 'value']); + expect(condition).not.toHaveProperty('valueSnapshot'); + + const update = (variableUpdate.inputs[0].value as any)[0]; + expect(update.variable).toEqual(['deleted-node', 'target']); + expect(update.value).toEqual([['deleted-node', 'value']]); + expect(update.valueReferenceSnapshots).toEqual([ + { reference: ['deleted-node', 'value'], sourceLabel: 'Deleted node' } + ]); + expect(await migrateWorkflowToCurrent(result as any)).toEqual(result); + }); }); describe('workflow migration boundary', () => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx index a07d307f2abc..98bf4a965300 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx @@ -154,7 +154,7 @@ const VariableSelector = ({ const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { referenceList, liveReferenceList } = useReference({ + const { referenceList, sourceNodeIds, sourceNodes } = useReference({ nodeId, valueType: inputChildren.valueType }); @@ -207,7 +207,9 @@ const VariableSelector = ({ t('common:core.module.Dataset quote.select') )} list={referenceList} - liveList={liveReferenceList} + sourceNodeIds={sourceNodeIds} + sourceNodes={sourceNodes} + valueType={inputChildren.valueType} referenceSnapshots={inputChildren.referenceSnapshots} value={inputChildren.value} onSelect={onSelect} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx index 09008d6932bc..c0ba379688e1 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx @@ -330,7 +330,7 @@ const VariableSelector = ({ }) => { const { t } = useTranslation(); - const { referenceList, liveReferenceList } = useReference({ + const { referenceList, sourceNodeIds, sourceNodes } = useReference({ nodeId, valueType: WorkflowIOValueTypeEnum.any }); @@ -339,7 +339,9 @@ const VariableSelector = ({ { @@ -595,7 +599,17 @@ const ConditionValueInput = ({ }} /> ); - }, [t, referenceList, liveReferenceList, valueSnapshot, isReference, value, updateValue]); + }, [ + t, + referenceList, + sourceNodeIds, + sourceNodes, + referenceValueType, + valueSnapshot, + isReference, + value, + updateValue + ]); const isDisabled = condition === VariableConditionEnum.isEmpty || condition === VariableConditionEnum.isNotEmpty; @@ -631,7 +645,7 @@ const ConditionValueInput = ({ if (isReference) { updateValue('', 'input'); } else { - updateValue(['', undefined], 'reference'); + updateValue(['', ''], 'reference'); } }} > diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx index 4d89a6f72391..45440b501524 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx @@ -125,7 +125,7 @@ function Reference({ [input, nodeId, onChangeNode] ); - const { referenceList, liveReferenceList } = useReference({ + const { referenceList, sourceNodeIds, sourceNodes } = useReference({ nodeId, valueType: input.valueType }); @@ -207,7 +207,9 @@ function Reference({ { const { t } = useTranslation(); - const { referenceList, liveReferenceList } = useReference({ + const { referenceList, sourceNodeIds, sourceNodes } = useReference({ nodeId, valueType }); @@ -27,7 +27,9 @@ const VariableSelector = ({ nodeId, variable, valueType, referenceSnapshots, onS ); const Avatar = dynamic(() => import('@fastgpt/web/components/common/Avatar')); -/** - * 判断引用指向的来源节点和输出是否仍存在(不依赖连线关系)。 - * 选择器列表只包含当前连线范围内的来源,凭此区分「来源已删除」和「断边导致来源不可达」: - * 来源仍存在但不在可选列表中,说明是连线断开导致不可选。 - */ -const referenceSourceStillExists = ({ - value, - getNodeById -}: { - value?: ReferenceItemValueType; - getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; -}) => { - if (!isConfiguredReferenceItem(value)) return false; - const [refNodeId, refOutputId] = value; - if (!refNodeId || !refOutputId || refNodeId === VARIABLE_NODE_ID) return false; - - const sourceNode = getNodeById(refNodeId); - if (!sourceNode) return false; - - const output = sourceNode.outputs.find((item) => item.id === refOutputId); - if (!output || output.invalid === true || output.id === NodeOutputKeyEnum.addOutputParam) { - return false; - } - if (output.type === FlowNodeOutputTypeEnum.error) return sourceNode.catchError === true; - - return true; -}; - type ReferenceSelectList = { label: string | React.ReactNode; value: string; @@ -153,13 +124,11 @@ const getReferenceSnapshotFromSource = ({ const getReferenceSnapshot = ({ value, list, - liveList, referenceSnapshots, getNodeById }: { value?: ReferenceItemValueType; list: ReferenceSelectList; - liveList: ReferenceSelectList; referenceSnapshots?: WorkflowReferenceSnapshot[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; }) => { @@ -167,38 +136,27 @@ const getReferenceSnapshot = ({ return ( getReferenceSnapshotFromList({ value, list }) ?? - getReferenceSnapshotFromList({ value, list: liveList }) ?? getReferenceSnapshotFromSource({ value, getNodeById }) ?? referenceSnapshots?.find((item) => isSameReference(item.reference, value)) ); }; -const getReferenceItems = (value?: ReferenceValueType): ReferenceItemValueType[] => { - if (isConfiguredReferenceItem(value)) return [value]; - if (!Array.isArray(value)) return []; - - return value.filter((item): item is ReferenceItemValueType => isConfiguredReferenceItem(item)); -}; - const formatReferenceSnapshots = ({ value, list, - liveList, referenceSnapshots, getNodeById }: { value?: ReferenceValueType; list: ReferenceSelectList; - liveList: ReferenceSelectList; referenceSnapshots?: WorkflowReferenceSnapshot[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; }) => - getReferenceItems(value) + getWorkflowReferenceItems(value) .map((item) => getReferenceSnapshot({ value: item, list, - liveList, referenceSnapshots, getNodeById }) @@ -233,8 +191,9 @@ const ReferenceSnapshotLabel = ({ type CommonSelectProps = { placeholder?: string; list: ReferenceSelectList; - // 不做类型过滤的可选输出列表,仅用于区分「来源已删除」和「类型不匹配」两种失效原因 - liveList?: ReferenceSelectList; + sourceNodeIds?: string[]; + sourceNodes?: WorkflowReferenceSourceNode[]; + valueType?: WorkflowIOValueTypeEnum; referenceSnapshots?: WorkflowReferenceSnapshot[]; popDirection?: 'top' | 'bottom'; ButtonProps?: ButtonProps; @@ -267,7 +226,7 @@ export const useReference = ({ ); // 获取可选的变量列表 - const { referenceList, liveReferenceList } = useMemoEnhance(() => { + const { referenceList, sourceNodeIds, sourceNodes } = useMemoEnhance(() => { const sourceNodes = getNodeAllSource({ nodeId, getNodeById, @@ -281,8 +240,6 @@ export const useReference = ({ const isArray = valueType?.includes('array'); const referenceList: ReferenceSelectList = []; - // 失效原因分类专用:类型不受限时的可选输出;来源仍在这里但不在 referenceList 中即为类型不匹配 - const liveReferenceList: ReferenceSelectList = []; sourceNodes.forEach((node) => { const label = ( @@ -313,23 +270,17 @@ export const useReference = ({ children: toChildren(selectableOutputs) }); } + }); - const liveOutputs = filterSelectableWorkflowNodeOutputs({ + return { + referenceList, + sourceNodeIds: sourceNodes.map((node) => node.nodeId), + sourceNodes: sourceNodes.map((node) => ({ + nodeId: node.nodeId, outputs: node.outputs, - valueType: WorkflowIOValueTypeEnum.any, catchError: node.catchError - }); - if (liveOutputs.length > 0) { - liveReferenceList.push({ - label, - value: node.nodeId, - sourceLabel: node.name, - children: toChildren(liveOutputs) - }); - } - }); - - return { referenceList, liveReferenceList }; + })) + }; }, [ nodeId, getNodeById, @@ -343,7 +294,8 @@ export const useReference = ({ return { referenceList, - liveReferenceList + sourceNodeIds, + sourceNodes }; }; @@ -371,7 +323,7 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { [item, nodeId, onChangeNode] ); - const { referenceList, liveReferenceList } = useReference({ + const { referenceList, sourceNodeIds, sourceNodes } = useReference({ nodeId, valueType: item.valueType }); @@ -386,7 +338,9 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { { - if ( - Array.isArray(value) && - // @ts-ignore - value.length === 1 && - Array.isArray(value[0]) && - value[0].length === 2 - ) { - // @ts-ignore - onSelect(value[0]); - } - }, [value, onSelect]); - const ItemSelector = useMemo(() => { const selectorVal = value as ReferenceItemValueType; const [nodeName, outputName] = getSelectValue(selectorVal, list); - const isValidSelect = Boolean(nodeName && outputName); + const status = getWorkflowReferenceStatus({ + value: selectorVal, + valueType, + sourceNodeIds, + sourceNodes, + getNodeById + }); + const isValidSelect = status.code === 'valid' && Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; - // 来源仍在无类型过滤列表中即为类型不匹配,否则按来源已删除提示 - const [liveNodeName, liveOutputName] = isInvalidReference - ? getSelectValue(selectorVal, liveList) - : []; - const isTypeMismatch = isInvalidReference && Boolean(liveNodeName && liveOutputName); - // 来源节点仍存在但不在可选列表中,即断边导致不可达;否则按来源已删除提示 - const isSourceUnreachable = - isInvalidReference && - !isTypeMismatch && - referenceSourceStillExists({ value: selectorVal, getNodeById }); const referenceOutputKey = getReferenceOutputKey(selectorVal); const referenceTitle = getReferenceValueTitle(selectorVal); - const invalidReason = isTypeMismatch - ? t('common:core.workflow.check.reference_type_mismatch') - : isSourceUnreachable - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted'); + const invalidReason = + status.code === 'invalid_reference_type' + ? t('common:core.workflow.check.reference_type_mismatch') + : status.code === 'unreachable_reference' + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted'); const invalidSnapshot = isInvalidReference ? getReferenceSnapshot({ value: selectorVal, list, - liveList, referenceSnapshots, getNodeById }) @@ -502,7 +441,6 @@ const SingleReferenceSelector = ({ const snapshots = formatReferenceSnapshots({ value: nextValue as ReferenceValueType | undefined, list, - liveList, referenceSnapshots, getNodeById }); @@ -537,13 +475,15 @@ const SingleReferenceSelector = ({ getSelectValue, getNodeById, list, - liveList, onSelect, placeholder, popDirection, referenceSnapshots, + sourceNodeIds, + sourceNodes, t, - value + value, + valueType ]); return ItemSelector; @@ -552,7 +492,9 @@ const MultipleReferenceSelector = ({ placeholder, value, list = [], - liveList = [], + sourceNodeIds, + sourceNodes, + valueType, referenceSnapshots, onSelect, popDirection @@ -580,36 +522,35 @@ const MultipleReferenceSelector = ({ return value.map((item) => { const [nodeName, outputName] = getSelectValue(item, list); + const status = getWorkflowReferenceStatus({ + value: item, + valueType, + sourceNodeIds, + sourceNodes, + getNodeById + }); return { rawValue: item, nodeName, - outputName + outputName, + status }; }); - }, [getSelectValue, list, value]); + }, [getNodeById, getSelectValue, list, sourceNodeIds, sourceNodes, value, valueType]); const handleSelect = useCallback( (nextValue?: ReferenceArrayValueType) => { const snapshots = formatReferenceSnapshots({ value: nextValue, list, - liveList, referenceSnapshots, getNodeById }); onSelect(nextValue, snapshots.length ? snapshots : undefined); }, - [getNodeById, list, liveList, onSelect, referenceSnapshots] + [getNodeById, list, onSelect, referenceSnapshots] ); - useEffect(() => { - // Adapt array type from old version - if (Array.isArray(value) && typeof value[0] === 'string') { - // @ts-ignore - handleSelect([value]); - } - }, [formatList, handleSelect, value]); - const ArraySelector = useMemo(() => { return ( - {formatList.map(({ rawValue, nodeName, outputName }, index) => { - const isValidReference = Boolean(nodeName && outputName); + {formatList.map(({ rawValue, nodeName, outputName, status }, index) => { + const isValidReference = status.code === 'valid' && Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(rawValue) && !isValidReference; - // 多选失效项区分原因:来源仍在(无类型过滤列表可命中)为类型不匹配,否则为已删除 - const [liveNodeName, liveOutputName] = isInvalidReference - ? getSelectValue(rawValue, liveList) - : []; - const isTypeMismatch = - isInvalidReference && Boolean(liveNodeName && liveOutputName); - // 来源节点仍存在但不在可选列表中,即断边导致不可达;否则按来源已删除提示 - const isSourceUnreachable = - isInvalidReference && - !isTypeMismatch && - referenceSourceStillExists({ value: rawValue, getNodeById }); const referenceOutputKey = getReferenceOutputKey(rawValue); const referenceTitle = getReferenceValueTitle(rawValue); - const invalidReason = isTypeMismatch - ? t('common:core.workflow.check.reference_type_mismatch') - : isSourceUnreachable - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted'); + const invalidReason = + status.code === 'invalid_reference_type' + ? t('common:core.workflow.check.reference_type_mismatch') + : status.code === 'unreachable_reference' + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted'); const invalidSnapshot = isInvalidReference ? getReferenceSnapshot({ value: rawValue, list, - liveList, referenceSnapshots, getNodeById }) @@ -738,10 +668,8 @@ const MultipleReferenceSelector = ({ ); }, [ formatList, - getSelectValue, getNodeById, list, - liveList, handleSelect, placeholder, popDirection, 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 4ca5191e15c4..b8408ba9156c 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -3,9 +3,9 @@ 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'; -import { getHandleId } from '@fastgpt/global/core/workflow/utils'; +import { getHandleId, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { OnConnectStartParams } from 'reactflow'; -import type { Edge } from 'reactflow'; import { WorkflowBufferDataContext } from './workflowInitContext'; import type { FlowNodeInputItemType, @@ -154,8 +154,6 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const singleNodeCheckTimerRef = useRef>>(new Map()); const edgeCheckTimerRef = useRef | null>(null); - // 删边影响范围:从被删边 target 沿剩余出边向下扩展;防抖检查时消费后清空 - const edgeImpactNodeIdsRef = useRef>(new Set()); const isFirstEdgesEffectRef = useRef(true); const prevEdgesRef = useRef(edges); @@ -308,16 +306,11 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const nodes = getNodes(); if (nodes.length === 0) return; - // 存在删边影响范围时追加断边引用检查;普通连线变化保持原有全量检查行为 - const impactNodeIds = edgeImpactNodeIdsRef.current; - edgeImpactNodeIdsRef.current = new Set(); - const issueMap = checkWorkflowNodeIssues({ nodes, edges, t, - chatConfig: appDetail.chatConfig, - referenceNodeIds: impactNodeIds.size > 0 ? [...impactNodeIds] : undefined + chatConfig: appDetail.chatConfig }); // 输出/catchError 变化产生新增类型不兼容时聚合提示一次,已有问题不重复提醒 @@ -337,30 +330,6 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }, 400); }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); - /** - * 从被删边的 target 出发,仅沿删除后的剩余出边向下遍历,得到断边影响节点集合。 - * 影响集合用于限定 unreachable_reference 判定范围,不维护持久化引用索引。 - */ - const collectEdgeDeletionImpactNodeIds = useCallback( - (removedEdges: Edge[], remainingEdges: Edge[]) => { - const impactNodeIds = new Set(); - const queue = removedEdges.map((edge) => edge.target); - - while (queue.length > 0) { - const nodeId = queue.shift(); - if (!nodeId || impactNodeIds.has(nodeId)) continue; - impactNodeIds.add(nodeId); - - remainingEdges.forEach((edge) => { - if (edge.source === nodeId) queue.push(edge.target); - }); - } - - return impactNodeIds; - }, - [] - ); - useEffect(() => { if (isFirstEdgesEffectRef.current) { isFirstEdgesEffectRef.current = false; @@ -403,21 +372,10 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }) ); } - - // 400ms 内可能连续删多条边,影响范围跨变化累积,由防抖检查统一消费 - collectEdgeDeletionImpactNodeIds(removedEdges, edges).forEach((nodeId) => - edgeImpactNodeIdsRef.current.add(nodeId) - ); } scheduleWorkflowCheckOnEdgeChange(); - }, [ - edges, - scheduleWorkflowCheckOnEdgeChange, - getNodes, - setNodes, - collectEdgeDeletionImpactNodeIds - ]); + }, [edges, scheduleWorkflowCheckOnEdgeChange, getNodes, setNodes]); useEffect(() => { const timers = singleNodeCheckTimerRef.current; @@ -427,7 +385,6 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod if (edgeCheckTimerRef.current) { clearTimeout(edgeCheckTimerRef.current); } - edgeImpactNodeIdsRef.current = new Set(); }; }, []); @@ -630,11 +587,26 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod // 但会触发 invalidCondition 重算,可能把兄弟输出置为 invalid); // - delOutput/replaceOutput 还会删边,与 edges effect 的全量检查重复, // 但两者共用同一个防抖 timer,最终只会扫描一次。 - const shouldRunFullWorkflowCheck = updateData.some( - (item) => - (item.type === 'attr' && item.key === 'catchError') || - ['updateOutput', 'replaceOutput', 'addOutput', 'delOutput'].includes(item.type) - ); + const shouldRunFullWorkflowCheck = updateData.some((item) => { + if (item.type === 'attr') { + return ['catchError', 'parentNodeId'].includes(item.key); + } + if (item.type === 'addInput') { + return ( + item.value.key === NodeInputKeyEnum.childrenNodeIdList || + nodeInputIsReference(item.value) + ); + } + if (item.type === 'delInput') { + return item.key === NodeInputKeyEnum.childrenNodeIdList; + } + if (item.type === 'updateInput' || item.type === 'replaceInput') { + return ( + item.key === NodeInputKeyEnum.childrenNodeIdList || nodeInputIsReference(item.value) + ); + } + return ['updateOutput', 'replaceOutput', 'addOutput', 'delOutput'].includes(item.type); + }); if (updateData.length > 1 || shouldRunFullWorkflowCheck) { scheduleWorkflowCheckOnEdgeChange(); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts index 4f700b7bd64e..bb5c154bed36 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts @@ -1,10 +1,7 @@ -import { getNodeAllSource } from '@/web/core/workflow/utils'; +import { filterSelectableWorkflowNodeOutputs, getNodeAllSource } from '@/web/core/workflow/utils'; import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { - FlowNodeOutputTypeEnum, - FlowNodeTypeEnum -} from '@fastgpt/global/core/workflow/node/constant'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; import { type FlowNodeItemType, @@ -168,17 +165,11 @@ export const getEditorVariables = ({ ? [] : sourceNodes .map((node) => { - return node.outputs - .filter((output) => { - if (output.type === FlowNodeOutputTypeEnum.error) { - return node.catchError === true; - } - return ( - !!output.label && - output.invalid !== true && - output.id !== NodeOutputKeyEnum.addOutputParam - ); - }) + return filterSelectableWorkflowNodeOutputs({ + outputs: node.outputs, + catchError: node.catchError + }) + .filter((output) => !!output.label) .map((output) => { return { label: safeT((output.label as any) || ''), diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 25d58bd1a6e9..f9cbe22d0e85 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -306,7 +306,7 @@ export const getRefData = ({ }: { variable?: ReferenceItemValueType; getNodeById: WorkflowDataContextType['getNodeById']; - chatConfig: AppChatConfigType; + chatConfig?: AppChatConfigType; }) => { if (!variable) return { @@ -315,13 +315,20 @@ export const getRefData = ({ }; const node = getNodeById(variable[0]); - const systemVariables = getWorkflowGlobalVariables({ chatConfig }); + if (!node && variable[0] === VARIABLE_NODE_ID) { + const globalVariable = getWorkflowGlobalVariables({ + chatConfig: chatConfig ?? {} + }).find((item) => item.key === variable[1]); + return { + valueType: globalVariable?.valueType ?? WorkflowIOValueTypeEnum.any, + required: !!globalVariable?.required + }; + } if (!node) { - const globalVariable = systemVariables.find((item) => item.key === variable?.[1]); return { - valueType: globalVariable?.valueType || WorkflowIOValueTypeEnum.any, - required: !!globalVariable?.required + valueType: WorkflowIOValueTypeEnum.any, + required: false }; } @@ -435,37 +442,127 @@ export const filterSelectableWorkflowNodeOutputs = ({ valueType?: WorkflowIOValueTypeEnum; catchError?: boolean; }) => { - return filterWorkflowNodeOutputsByType(outputs, valueType ?? WorkflowIOValueTypeEnum.any).filter( - (output) => { - if (output.type === FlowNodeOutputTypeEnum.error) { - return catchError === true; - } - - return output.id !== NodeOutputKeyEnum.addOutputParam && output.invalid !== true; + const selectableOutputs = outputs.filter((output) => { + if (output.type === FlowNodeOutputTypeEnum.error) { + return catchError === true; } + + return output.id !== NodeOutputKeyEnum.addOutputParam && output.invalid !== true; + }); + + return filterWorkflowNodeOutputsByType( + selectableOutputs, + valueType ?? WorkflowIOValueTypeEnum.any ); }; -const referenceItemIsSelectable = ({ +const isReferenceItem = (value: unknown): value is ReferenceItemValueType => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + typeof value[1] === 'string' && + value[0].length > 0 && + value[1].length > 0; + +/** 从 canonical 单选或多选值中按原顺序提取引用项。 */ +export const getWorkflowReferenceItems = (value: unknown): ReferenceItemValueType[] => { + if (isReferenceItem(value)) return [value]; + if (!Array.isArray(value)) return []; + + return value.filter(isReferenceItem); +}; + +export const isEmptyReferenceValue = (value: unknown) => + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && + (value.length === 0 || + (value.length === 2 && + ((value[0] === '' && value[1] === '') || + (value[0] === undefined && value[1] === undefined))))); + +/** 空引用不参与状态检查;其他非空值统一视为已配置并报告 invalid_reference。 */ +export const isConfiguredReferenceValue = (value: unknown) => !isEmptyReferenceValue(value); + +export type WorkflowReferenceStatusCode = + | 'empty' + | 'valid' + | 'invalid_reference' + | 'unreachable_reference' + | 'invalid_reference_type'; + +export type WorkflowReferenceStatus = { + code: WorkflowReferenceStatusCode; + sourceType?: WorkflowIOValueTypeEnum; +}; + +/** + * 判断单项引用的当前状态,统一处理来源、输出、可达范围和类型。 + * sourceNodeIds 只表达当前消费节点允许的普通来源;global reference 单独按 chatConfig 查询。 + */ +export const getWorkflowReferenceStatus = ({ value, + valueType, + sourceNodeIds, sourceNodes, - valueType + getNodeById, + chatConfig }: { - value: ReferenceItemValueType; - sourceNodes: WorkflowReferenceSourceNode[]; + value: unknown; valueType?: WorkflowIOValueTypeEnum; -}) => { + sourceNodeIds?: Iterable; + sourceNodes?: WorkflowReferenceSourceNode[]; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; + chatConfig?: AppChatConfigType; +}): WorkflowReferenceStatus => { + if (!isConfiguredReferenceValue(value)) return { code: 'empty' }; + if (!isReferenceItem(value)) return { code: 'invalid_reference' }; + const [sourceNodeId, outputId] = value; - if (!sourceNodeId || !outputId) return false; + const sourceNode = + sourceNodes?.find((node) => node.nodeId === sourceNodeId) ?? getNodeById(sourceNodeId); + const sourceOutput = sourceNode?.outputs.find((output) => output.id === outputId); - const sourceNode = sourceNodes.find((node) => node.nodeId === sourceNodeId); - if (!sourceNode) return false; + if (sourceNodeId === VARIABLE_NODE_ID && !sourceOutput) { + // 缺少 chatConfig 时无法判断全局变量是否存在,保持旧数据检查的未知可继续语义。 + if (chatConfig === undefined) return { code: 'valid' }; + + const globalVariable = getWorkflowGlobalVariables({ + chatConfig + }).find((variable) => variable.key === outputId); + if (!globalVariable) return { code: 'invalid_reference' }; + if (!workflowValueTypeIsCompatible(globalVariable.valueType, valueType)) { + return { + code: 'invalid_reference_type', + sourceType: globalVariable.valueType + }; + } + return { code: 'valid', sourceType: globalVariable.valueType }; + } - return filterSelectableWorkflowNodeOutputs({ - outputs: sourceNode.outputs, - valueType, + if (!sourceNode || !sourceOutput) return { code: 'invalid_reference' }; + if ( + sourceNodeId !== VARIABLE_NODE_ID && + sourceNodeIds && + !new Set(sourceNodeIds).has(sourceNodeId) + ) { + return { code: 'unreachable_reference', sourceType: sourceOutput.valueType }; + } + + const selectableOutput = filterSelectableWorkflowNodeOutputs({ + outputs: [sourceOutput], + valueType: WorkflowIOValueTypeEnum.any, catchError: sourceNode.catchError - }).some((output) => output.id === outputId); + }); + if (!selectableOutput.length) { + return { code: 'invalid_reference', sourceType: sourceOutput.valueType }; + } + if (!workflowValueTypeIsCompatible(sourceOutput.valueType, valueType)) { + return { code: 'invalid_reference_type', sourceType: sourceOutput.valueType }; + } + + return { code: 'valid', sourceType: sourceOutput.valueType }; }; /** @@ -481,128 +578,124 @@ export const workflowReferenceValueIsSelectable = ({ sourceNodes: WorkflowReferenceSourceNode[]; valueType?: WorkflowIOValueTypeEnum; }) => { - if (!Array.isArray(value)) return false; - - if (typeof value[0] === 'string') { - return referenceItemIsSelectable({ - value: value as ReferenceItemValueType, - sourceNodes, - valueType - }); - } - - return value.some((item) => { - if (!Array.isArray(item)) return false; - - return referenceItemIsSelectable({ - value: item as ReferenceItemValueType, - sourceNodes, - valueType - }); - }); + return getWorkflowReferenceItems(value).some( + (item) => + getWorkflowReferenceStatus({ + value: item, + valueType, + sourceNodes, + sourceNodeIds: sourceNodes.map((node) => node.nodeId), + getNodeById: () => undefined + }).code === 'valid' + ); }; /** - * 获取当前节点可引用的所有上游节点。 - * 结果按工作流入边距离由近到远排列;嵌套节点先取自身入边,再取父容器入边, - * 最后追加全局变量,保证引用选择器优先展示最近的可用输出。 + * 获取当前节点可引用的普通来源 ID。 + * 按当前节点到根容器的入边和 reference 输入遍历,visited 防止坏 parent 数据循环。 */ -export const getNodeAllSource = ({ +export const getNodeAllSourceIds = ({ nodeId, getNodeById, edges, - chatConfig, - t, includeChildren, childrenNodeIdListMap }: { nodeId: string; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; edges: Edge[]; - chatConfig: AppChatConfigType; - t: TFunction; includeChildren?: boolean; childrenNodeIdListMap?: Record; -}): FlowNodeItemType[] => { - // get current node +}): string[] => { const node = getNodeById(nodeId); - if (!node) { - return []; - } + if (!node) return []; - const parentId = node.parentNodeId; - const sourceNodes = new Map(); + const sourceIds = new Set(); const searchedTargetNodeIds = new Set(); - - // 按入边层级遍历,避免深度优先递归把更远的上游节点排到直接来源前面。 - const collectSourceNodesByEdgeDistance = (targetNodeIds: string[]) => { + const collectIncoming = (targetNodeIds: string[]) => { const queue = targetNodeIds.filter(Boolean); - while (queue.length > 0) { const targetNodeId = queue.shift(); if (!targetNodeId || searchedTargetNodeIds.has(targetNodeId)) continue; searchedTargetNodeIds.add(targetNodeId); - - const targetEdges = edges.filter((item) => item.target === targetNodeId); - targetEdges.forEach((edge) => { - const sourceNode = getNodeById(edge.source); - if (!sourceNode) return; - - if (!sourceNodes.has(sourceNode.nodeId)) { - sourceNodes.set(sourceNode.nodeId, sourceNode); - } - - queue.push(sourceNode.nodeId); + edges.forEach((edge) => { + if (edge.target !== targetNodeId) return; + if (!getNodeById(edge.source)) return; + sourceIds.add(edge.source); + queue.push(edge.source); }); } }; - collectSourceNodesByEdgeDistance([nodeId]); - - if (parentId) { - collectSourceNodesByEdgeDistance([parentId]); + const containerNodes = [node]; + const visitedParentIds = new Set([node.nodeId]); + let parentNode = node; + while (parentNode.parentNodeId && !visitedParentIds.has(parentNode.parentNodeId)) { + const nextParent = getNodeById(parentNode.parentNodeId); + if (!nextParent) break; + containerNodes.push(nextParent); + visitedParentIds.add(nextParent.nodeId); + parentNode = nextParent; } - - // 对于嵌套在容器(Loop/ParallelRun)内的节点,容器的 reference 类型输入 - // 是通过引用选择器设置的(存在 input.value = [nodeId, outputId]),不产生 ReactFlow edge。 - // 因此需要额外扫描父容器的 reference 输入,将被引用的外部节点补充到可选来源中。 - if (parentId) { - const parentNode = getNodeById(parentId); - if (parentNode) { - parentNode.inputs.forEach((input) => { - if (!nodeInputIsReference(input)) return; - const val = input.value as ReferenceItemValueType | undefined; - if (!Array.isArray(val) || val.length < 2) return; - const [refNodeId] = val; - if (!refNodeId || refNodeId === VARIABLE_NODE_ID) return; - const refNode = getNodeById(refNodeId); - if (!refNode || sourceNodes.has(refNode.nodeId)) return; - sourceNodes.set(refNode.nodeId, refNode); - collectSourceNodesByEdgeDistance([refNode.nodeId]); + collectIncoming(containerNodes.map((item) => item.nodeId)); + + containerNodes.slice(1).forEach((container) => { + container.inputs.forEach((input) => { + if (!nodeInputIsReference(input)) return; + getWorkflowReferenceItems(input.value).forEach(([refNodeId]) => { + if (refNodeId === VARIABLE_NODE_ID || !getNodeById(refNodeId)) return; + sourceIds.add(refNodeId); + collectIncoming([refNodeId]); }); - } - } + }); + }); - // Edge traversal only reaches upstream; children must be added explicitly. if (includeChildren && childrenNodeIdListMap) { - const childIds = childrenNodeIdListMap[nodeId] ?? []; - childIds.forEach((childId) => { - if (sourceNodes.has(childId)) return; - const childNode = getNodeById(childId); - if (!childNode) return; - sourceNodes.set(childId, childNode); + (childrenNodeIdListMap[nodeId] ?? []).forEach((childId) => { + if (getNodeById(childId)) sourceIds.add(childId); }); } - sourceNodes.set( - 'system_global_variable', + return [...sourceIds]; +}; + +/** 获取当前节点可引用的来源节点,并追加 global variable 节点供 selector 展示。 */ +export const getNodeAllSource = ({ + nodeId, + getNodeById, + edges, + chatConfig, + t, + includeChildren, + childrenNodeIdListMap +}: { + nodeId: string; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; + edges: Edge[]; + chatConfig: AppChatConfigType; + t: TFunction; + includeChildren?: boolean; + childrenNodeIdListMap?: Record; +}): FlowNodeItemType[] => { + if (!getNodeById(nodeId)) return []; + + const sourceNodes = getNodeAllSourceIds({ + nodeId, + getNodeById, + edges, + includeChildren, + childrenNodeIdListMap + }) + .map((sourceNodeId) => getNodeById(sourceNodeId)) + .filter((sourceNode): sourceNode is FlowNodeItemType => !!sourceNode); + + return [ + ...sourceNodes, getGlobalVariableNode({ t, chatConfig }) - ); - - return Array.from(sourceNodes.values()); + ]; }; /* ====== Variables ======= */ diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index c2f5fd78b3a5..3bd02dd3f100 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -6,27 +6,16 @@ import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { Edge, Node } from 'reactflow'; import { FlowNodeInputTypeEnum, - FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum, NodeOutputKeyEnum, - VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { - getHandleId, - isValidReferenceValue, - isValidReferenceValueFormat, - nodeInputIsReference -} from '@fastgpt/global/core/workflow/utils'; +import { getHandleId, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; import type { TFunction } from 'next-i18next'; -import type { - FlowNodeInputItemType, - FlowNodeOutputItemType, - ReferenceItemValueType -} from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { AppChatConfigType } from '@fastgpt/global/core/app/type'; import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; @@ -44,21 +33,22 @@ import { isAgentGeneratedToolInput } from '@fastgpt/global/core/app/formEdit/utils'; import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; -import { workflowValueTypeIsCompatible } from './utils'; +import { + getNodeAllSourceIds, + getRefData, + getWorkflowReferenceItems, + getWorkflowReferenceStatus, + isConfiguredReferenceValue, + isEmptyReferenceValue, + workflowValueTypeIsCompatible +} from './utils'; type WorkflowCheckContext = { nodeMap: Map>; - nodeOutputMap: Map>; incomingEdgesMap: Map[]>; outgoingEdgesMap: Map[]>; reachableNodeSet: Set; chatConfig?: AppChatConfigType; - // 未按 sourceHandle 过滤的入边表,来源集合遍历需要与编辑器 getNodeAllSource 一致 - rawIncomingEdgesMap: Map[]>; - // 容器(Loop/ParallelRun)按 parentNodeId 归组的 children - childrenMap: Map; - // 删边影响范围;缺省时不产出 unreachable_reference,保持全量检查行为不变 - referenceNodeIdSet?: Set; }; const workflowCheckSkipConnectionTypes = new Set([ @@ -353,43 +343,23 @@ export const getWorkflowCheckIssueMessage = ( const createWorkflowCheckContext = ({ nodes, edges, - chatConfig, - referenceNodeIds + chatConfig }: { nodes: Node[]; edges: Edge[]; chatConfig?: AppChatConfigType; - referenceNodeIds?: string[]; }): WorkflowCheckContext => { const nodeMap = new Map>(); - const nodeOutputMap = new Map>(); const incomingEdgesMap = new Map[]>(); const outgoingEdgesMap = new Map[]>(); - const rawIncomingEdgesMap = new Map[]>(); - const childrenMap = new Map(); nodes.forEach((node) => { nodeMap.set(node.data.nodeId, node); - nodeOutputMap.set( - node.data.nodeId, - new Map(node.data.outputs.map((output) => [output.id, output])) - ); incomingEdgesMap.set(node.data.nodeId, []); outgoingEdgesMap.set(node.data.nodeId, []); - rawIncomingEdgesMap.set(node.data.nodeId, []); - - const parentId = node.data.parentNodeId; - if (parentId) { - childrenMap.set(parentId, [...(childrenMap.get(parentId) ?? []), node.data.nodeId]); - } }); edges.forEach((edge) => { - // 来源集合遍历不做 sourceHandle 过滤,与编辑器选择器看到的连线保持一致 - if (nodeMap.has(edge.source) && nodeMap.has(edge.target)) { - rawIncomingEdgesMap.get(edge.target)?.push(edge); - } - const sourceNode = nodeMap.get(edge.source); if (!isWorkflowEdgeSourceHandleValid(sourceNode, edge.sourceHandle)) { return; @@ -420,274 +390,59 @@ const createWorkflowCheckContext = ({ return { nodeMap, - nodeOutputMap, incomingEdgesMap, outgoingEdgesMap, reachableNodeSet, - chatConfig, - rawIncomingEdgesMap, - childrenMap, - referenceNodeIdSet: referenceNodeIds ? new Set(referenceNodeIds) : undefined + chatConfig }; }; -const referenceValueIsLive = ( - value: ReferenceItemValueType | undefined, - context: WorkflowCheckContext -) => { - if (!isValidReferenceValueFormat(value)) return false; - const [refNodeId, refOutputId] = value; - if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return false; - if (!refNodeId || !refOutputId) return false; - if (refNodeId === VARIABLE_NODE_ID) { - return context.chatConfig === undefined - ? true - : context.chatConfig.variables?.some((variable) => variable.key === refOutputId) === true; - } - - const output = context.nodeOutputMap.get(refNodeId)?.get(refOutputId); - if (!output || output.invalid === true || output.id === NodeOutputKeyEnum.addOutputParam) { - return false; +const getReferenceStatuses = ({ + value, + valueType, + sourceNodeIds, + context +}: { + value: unknown; + valueType?: WorkflowIOValueTypeEnum; + sourceNodeIds: string[]; + context: WorkflowCheckContext; +}) => { + const referenceItems = getWorkflowReferenceItems(value); + if (referenceItems.length === 0) { + return isConfiguredReferenceValue(value) ? [{ code: 'invalid_reference' as const }] : []; } - return ( - output.type !== FlowNodeOutputTypeEnum.error || - context.nodeMap.get(refNodeId)?.data.catchError === true + const getNodeById = (nodeId: string | null | undefined) => + context.nodeMap.get(nodeId ?? '')?.data; + return referenceItems.map((item) => + getWorkflowReferenceStatus({ + value: item, + valueType, + sourceNodeIds, + getNodeById, + chatConfig: context.chatConfig + }) ); }; -/** 仅把空值和两个历史空占位视为未选择,其余非空 malformed value 都应报告 invalid_reference。 */ -const isUnsetReferenceValue = (value: unknown) => { - if (value === undefined || value === null || value === '') return true; - if (!Array.isArray(value)) return false; - if (value.length === 0) return true; - - // 保留历史数据中的 ['', ''] 和 [undefined, undefined] 空占位兼容行为。 - if (value.length === 2 && !Array.isArray(value[0])) { - const [refNodeId, refOutputId] = value; - return ( - (refNodeId === '' && refOutputId === '') || - (refNodeId === undefined && refOutputId === undefined) - ); - } - - return false; -}; - -const isEmptyReferenceInputValue = (value: unknown, isArrayType: boolean) => { - if (isArrayType) { - return !Array.isArray(value) || value.length === 0; - } - return isUnsetReferenceValue(value); -}; - -/** - * 判断引用值是否「已配置」:空值和 ['', ''] / [undefined, undefined] 两个历史空占位视为未选择, - * 其余非空值(含格式错误的值)一律视为已配置。 - * 运行检查与引用选择器 UI 共用,避免空占位判定标准两处维护产生漂移。 - */ -export const isConfiguredReferenceValue = (value: unknown) => !isUnsetReferenceValue(value); - -const getReferenceItems = (value: unknown): ReferenceItemValueType[] => { - if (Array.isArray(value) && typeof value[0] === 'string') { - return [value as ReferenceItemValueType]; - } - - return Array.isArray(value) - ? (value as ReferenceItemValueType[]) - : [value as ReferenceItemValueType]; -}; - -const referenceValueHasInvalidSource = (value: unknown, context: WorkflowCheckContext) => - isConfiguredReferenceValue(value) && - getReferenceItems(value).some((item) => !referenceValueIsLive(item, context)); - -/** - * 收集节点当前可引用的来源节点集合,遍历规则与编辑器 getNodeAllSource 保持一致: - * 沿入边反向遍历,嵌套节点追加父容器入边和父容器 reference 输入指向的外部节点, - * 容器自身 children 也属于可选来源。全局变量不依赖连线,按 VARIABLE_NODE_ID 单独判定。 - */ -const getNodeReferenceSourceSet = ( - node: Node, - context: WorkflowCheckContext -): Set => { - const sourceSet = new Set(); - const visited = new Set(); - - const walkIncoming = (nodeId: string) => { - if (visited.has(nodeId)) return; - visited.add(nodeId); - - context.rawIncomingEdgesMap.get(nodeId)?.forEach((edge) => { - sourceSet.add(edge.source); - walkIncoming(edge.source); - }); - }; - - walkIncoming(node.data.nodeId); - - const parentId = node.data.parentNodeId; - if (parentId) { - walkIncoming(parentId); - - // 容器 reference 输入不产生 ReactFlow edge,被引用的外部节点需显式补充,避免误报 - context.nodeMap.get(parentId)?.data.inputs.forEach((input) => { - if (!nodeInputIsReference(input)) return; - const value = input.value; - if (!Array.isArray(value)) return; - const [refNodeId] = value as ReferenceItemValueType; - if (typeof refNodeId !== 'string' || !refNodeId || refNodeId === VARIABLE_NODE_ID) return; - if (!context.nodeMap.has(refNodeId) || sourceSet.has(refNodeId)) return; - sourceSet.add(refNodeId); - walkIncoming(refNodeId); - }); - } - - context.childrenMap.get(node.data.nodeId)?.forEach((childId) => sourceSet.add(childId)); - - return sourceSet; -}; - -/** - * 判断单个仍 live 的引用项是否因删除边而不可达: - * 来源节点不在当前节点允许来源集合内,或来源节点已无法从工作流入口到达。 - * 来源消失由 invalid_reference 处理;全局变量恒不视为不可达。 - */ -const referenceItemIsUnreachable = ( - value: ReferenceItemValueType, - sourceSet: Set, - context: WorkflowCheckContext -) => { - if (!referenceValueIsLive(value, context)) return false; - const [refNodeId] = value; - if (refNodeId === VARIABLE_NODE_ID) return false; - if (!sourceSet.has(refNodeId)) return true; - return !context.reachableNodeSet.has(refNodeId); -}; - -/** 已配置引用中任一项不可达即报字段级问题,与 invalid_reference 语义一致。 */ -const referenceValueHasUnreachableSource = ( - value: unknown, - sourceSet: Set | undefined, - context: WorkflowCheckContext -) => - !!sourceSet && - isConfiguredReferenceValue(value) && - getReferenceItems(value).some((item) => referenceItemIsUnreachable(item, sourceSet, context)); - -const isVariableUpdateValueUnreachable = ( - item: TUpdateListItem, - sourceSet: Set | undefined, - context: WorkflowCheckContext -) => - item.renderType === FlowNodeInputTypeEnum.reference && - referenceValueHasUnreachableSource(item.value, sourceSet, context); - -const isVariableUpdateTargetUnreachable = ( - item: TUpdateListItem, - sourceSet: Set | undefined, - context: WorkflowCheckContext -) => - !!sourceSet && - referenceItemIsUnreachable(item.variable as ReferenceItemValueType, sourceSet, context); - -/** 读取引用来源的当前 valueType:全局变量取 chatConfig 定义,普通节点取输出定义。 */ -const getReferenceSourceValueType = ( - value: ReferenceItemValueType, - context: WorkflowCheckContext -): WorkflowIOValueTypeEnum | undefined => { - const [refNodeId, refOutputId] = value; - if (typeof refNodeId !== 'string' || typeof refOutputId !== 'string') return undefined; - if (refNodeId === VARIABLE_NODE_ID) { - return context.chatConfig?.variables?.find((variable) => variable.key === refOutputId) - ?.valueType; - } - return context.nodeOutputMap.get(refNodeId)?.get(refOutputId)?.valueType; -}; - -/** - * 判断已配置引用是否存在来源类型与目标类型不兼容的项。 - * 只检查来源仍 live 的引用,来源消失统一归 invalid_reference; - * 多选任一项不兼容即返回 true,与 invalid_reference 语义一致。 - */ -const referenceValueHasTypeMismatch = ( - value: unknown, - targetType: WorkflowIOValueTypeEnum | undefined, - context: WorkflowCheckContext -) => - isConfiguredReferenceValue(value) && - getReferenceItems(value).some( - (item) => - referenceValueIsLive(item, context) && - !workflowValueTypeIsCompatible(getReferenceSourceValueType(item, context), targetType) +const getReferenceIssueCode = (statuses: ReturnType) => + ['invalid_reference', 'unreachable_reference', 'invalid_reference_type'].find((code) => + statuses.some((status) => status.code === code) ); -const isVariableUpdateTargetEmpty = ( - variable: unknown, - nodeIds: string[], - context: WorkflowCheckContext -) => - !isValidReferenceValue(variable, nodeIds) || - !referenceValueIsLive(variable as ReferenceItemValueType, context); +const isEmptyReferenceInputValue = (value: unknown) => isEmptyReferenceValue(value); -const isVariableUpdateValueEmpty = (item: TUpdateListItem, context: WorkflowCheckContext) => { +const isVariableUpdateValueEmpty = (item: TUpdateListItem) => { if (item.renderType === FlowNodeInputTypeEnum.reference) { - if (isValidReferenceValueFormat(item.value)) { - return !referenceValueIsLive(item.value as ReferenceItemValueType, context); - } - return ( - !Array.isArray(item.value) || - item.value.length === 0 || - (item.value as ReferenceItemValueType[]).some((v) => !referenceValueIsLive(v, context)) - ); + return !isConfiguredReferenceValue(item.value); } - if (item.arrayMode === 'clear') return false; - if (item.booleanMode) return false; + if (item.arrayMode === 'clear' || item.booleanMode) return false; const inputVal = item.value?.[1]; return inputVal === undefined || inputVal === null || inputVal === ''; }; -const isVariableUpdateValueInvalid = (item: TUpdateListItem, context: WorkflowCheckContext) => - item.renderType === FlowNodeInputTypeEnum.reference && - referenceValueHasInvalidSource(item.value, context); - -/** - * 更新项目标变量的缓存 valueType 与变量当前类型不兼容时报类型问题。 - * 运行时按缓存类型格式化写入值,缓存过期即向新类型变量写入偏离语义的值; - * chatConfig 缺失时全局变量当前类型不可知,谓词按兼容处理,不误报。 - */ -const isVariableUpdateTargetTypeMismatch = ( - item: TUpdateListItem, - context: WorkflowCheckContext -) => { - if (!item.valueType) return false; - if (!isValidReferenceValueFormat(item.variable)) return false; - const currentType = getReferenceSourceValueType(item.variable as ReferenceItemValueType, context); - return !workflowValueTypeIsCompatible(item.valueType, currentType); -}; - -/** - * 更新项 reference 模式值与目标变量当前类型不兼容时报类型问题。 - * 目标变量已消失时由 invalid_reference 处理,不重复报类型问题。 - */ -const isVariableUpdateValueTypeMismatch = ( - item: TUpdateListItem, - context: WorkflowCheckContext -) => { - if (item.renderType !== FlowNodeInputTypeEnum.reference) return false; - if (!isConfiguredReferenceValue(item.value)) return false; - if (!isValidReferenceValueFormat(item.variable)) return false; - if (!referenceValueIsLive(item.variable as ReferenceItemValueType, context)) return false; - - const targetType = getReferenceSourceValueType(item.variable as ReferenceItemValueType, context); - return getReferenceItems(item.value).some( - (refItem) => - referenceValueIsLive(refItem, context) && - !workflowValueTypeIsCompatible(getReferenceSourceValueType(refItem, context), targetType) - ); -}; - /** * 结构化校验工作流节点和连线。 * 函数只读取入参并返回每个节点的错误列表,调用方负责写入 React state、toast 或定位画布。 @@ -697,19 +452,16 @@ export const checkWorkflowNodeIssues = ({ edges, nodeId, t, - chatConfig, - referenceNodeIds + chatConfig }: { nodes: Node[]; edges: Edge[]; nodeId?: string; t?: TFunction; chatConfig?: AppChatConfigType; - referenceNodeIds?: string[]; }): WorkflowCheckNodeIssueMap => { - const context = createWorkflowCheckContext({ nodes, edges, chatConfig, referenceNodeIds }); + const context = createWorkflowCheckContext({ nodes, edges, chatConfig }); const issueMap: WorkflowCheckNodeIssueMap = {}; - const nodeIds = nodes.map((node) => node.data.nodeId); const targetNodes = nodeId ? nodes.filter((node) => node.data.nodeId === nodeId) : nodes; const addIssue = ({ @@ -723,6 +475,9 @@ export const checkWorkflowNodeIssues = ({ message: string; inputKey?: string; }) => { + const nodeIssues = issueMap[node.data.nodeId] ?? []; + if (nodeIssues.some((issue) => issue.code === code && issue.inputKey === inputKey)) return; + const issue: WorkflowCheckIssue = { nodeId: node.data.nodeId, nodeName: node.data.name, @@ -732,17 +487,18 @@ export const checkWorkflowNodeIssues = ({ message, inputKey }; - issueMap[node.data.nodeId] = [...(issueMap[node.data.nodeId] ?? []), issue]; + issueMap[node.data.nodeId] = [...nodeIssues, 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 referenceSourceSet = context.referenceNodeIdSet?.has(data.nodeId) - ? getNodeReferenceSourceSet(node, context) - : undefined; + const sourceNodeIds = getNodeAllSourceIds({ + nodeId: data.nodeId, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + edges + }); const isToolNode = context.incomingEdgesMap .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); @@ -888,7 +644,7 @@ export const checkWorkflowNodeIssues = ({ if (!input.canEdit) { return false; } - return !input.key || !input.label || isUnsetReferenceValue(input.value); + return !input.key || !input.label || isEmptyReferenceValue(input.value); }); if (hasIncompleteDynamicInput) { addIssue({ @@ -1006,46 +762,68 @@ export const checkWorkflowNodeIssues = ({ addVariableUpdateIssue({ field: 'value', code: 'required_input_empty' }); } else { updateList.forEach((item, index) => { - if (isVariableUpdateTargetEmpty(item.variable, nodeIds, context)) { + const targetStatuses = getReferenceStatuses({ + value: item.variable, + sourceNodeIds, + context + }); + const targetIssueCode = getReferenceIssueCode(targetStatuses); + const targetType = getRefData({ + variable: item.variable, + getNodeById: (targetNodeId) => context.nodeMap.get(targetNodeId ?? '')?.data, + chatConfig: context.chatConfig + }).valueType; + + if (!isConfiguredReferenceValue(item.variable)) { addVariableUpdateIssue({ field: 'variable', index, - code: isConfiguredReferenceValue(item.variable) - ? 'invalid_reference' - : 'required_input_empty' + code: 'required_input_empty' }); - } else if (isVariableUpdateTargetUnreachable(item, referenceSourceSet, context)) { + } else if (targetIssueCode) { addVariableUpdateIssue({ field: 'variable', index, - code: 'unreachable_reference' + code: targetIssueCode as + | 'invalid_reference' + | 'invalid_reference_type' + | 'unreachable_reference' }); - } else if (isVariableUpdateTargetTypeMismatch(item, context)) { + } else if ( + item.valueType && + !workflowValueTypeIsCompatible(item.valueType, targetType) + ) { addVariableUpdateIssue({ field: 'variable', index, code: 'invalid_reference_type' }); } - if (isVariableUpdateValueEmpty(item, context)) { - addVariableUpdateIssue({ - field: 'value', - index, - code: isVariableUpdateValueInvalid(item, context) - ? 'invalid_reference' - : 'required_input_empty' - }); - } else if (isVariableUpdateValueUnreachable(item, referenceSourceSet, context)) { + + const valueStatuses = + item.renderType === FlowNodeInputTypeEnum.reference + ? getReferenceStatuses({ + value: item.value, + valueType: targetType, + sourceNodeIds, + context + }) + : []; + const valueIssueCode = getReferenceIssueCode(valueStatuses); + if (isVariableUpdateValueEmpty(item)) { addVariableUpdateIssue({ field: 'value', index, - code: 'unreachable_reference' + code: 'required_input_empty' }); - } else if (isVariableUpdateValueTypeMismatch(item, context)) { + } else if (valueIssueCode) { addVariableUpdateIssue({ field: 'value', index, - code: 'invalid_reference_type' + code: valueIssueCode as + | 'invalid_reference' + | 'invalid_reference_type' + | 'unreachable_reference' }); } }); @@ -1066,40 +844,24 @@ export const checkWorkflowNodeIssues = ({ } const isReferenceInput = nodeInputIsReference(input); - const isArrayReference = isReferenceInput && !!input.valueType?.startsWith('array'); // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 const effectiveInputValue = input.value ?? input.defaultValue; - // 已配置的引用始终检查,即使目标输入没有 valueType 或 required=false。 - if (isReferenceInput && referenceValueHasInvalidSource(effectiveInputValue, context)) { - addIssue({ - node, - code: 'invalid_reference', - message: getWorkflowCheckIssueMessage('invalid_reference', t, { - inputName: getInputLabel(input, t) - }), - inputKey: input.key - }); - } else if ( - isReferenceInput && - referenceValueHasUnreachableSource(effectiveInputValue, referenceSourceSet, context) - ) { - addIssue({ - node, - code: 'unreachable_reference', - message: getWorkflowCheckIssueMessage('unreachable_reference', t, { - inputName: getInputLabel(input, t) - }), - inputKey: input.key - }); - } else if ( - isReferenceInput && - referenceValueHasTypeMismatch(effectiveInputValue, input.valueType, context) - ) { + const referenceIssueCode = isReferenceInput + ? getReferenceIssueCode( + getReferenceStatuses({ + value: effectiveInputValue, + valueType: input.valueType, + sourceNodeIds, + context + }) + ) + : undefined; + if (referenceIssueCode) { addIssue({ node, - code: 'invalid_reference_type', - message: getWorkflowCheckIssueMessage('invalid_reference_type', t, { + code: referenceIssueCode, + message: getWorkflowCheckIssueMessage(referenceIssueCode, t, { inputName: getInputLabel(input, t) }), inputKey: input.key @@ -1126,7 +888,7 @@ export const checkWorkflowNodeIssues = ({ } const inputValueIsEmpty = isReferenceInput - ? isEmptyReferenceInputValue(effectiveInputValue, isArrayReference) + ? isEmptyReferenceInputValue(effectiveInputValue) : isEmptyWorkflowInputValue(effectiveInputValue); if ( 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 a04928937e74..0c9596eb70b7 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -692,58 +692,24 @@ describe('checkWorkflowNodeIssues', () => { value }); - // 链式 A -> B -> C -> D 删除 A -> B 后:B、C、D 进入影响范围 - const chainNodesAndEdges = () => { + it('checks every node against its current source scope', () => { const nodeA = makeOutputNode('A'); const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-a', ['A', 'out'])], - outputs: [ - { - id: 'out', - key: 'out', - label: 'out', - type: FlowNodeOutputTypeEnum.static, - valueType: WorkflowIOValueTypeEnum.string - } - ] + inputs: [makeReferenceInput('ref-a', ['A', 'out'])] }); const nodeC = makeNode('C', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-a', ['A', 'out'])], - outputs: [ - { - id: 'out', - key: 'out', - label: 'out', - type: FlowNodeOutputTypeEnum.static, - valueType: WorkflowIOValueTypeEnum.string - } - ] - }); - const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-b', ['B', 'out'])] + inputs: [makeReferenceInput('ref-a', ['A', 'out'])] }); - return { - nodes: [startNode, nodeA, nodeB, nodeC, nodeD], - // A -> B 已删除,仅剩 start -> A、B -> C、C -> D + const result = checkWorkflowNodeIssues({ + nodes: [startNode, nodeA, nodeB, nodeC], edges: [ { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, - { id: 'e-b-c', source: 'B', target: 'C', type: EDGE_TYPE }, - { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } + { id: 'e-start-b', source: 'start', target: 'B', type: EDGE_TYPE }, + { id: 'e-b-c', source: 'B', target: 'C', type: EDGE_TYPE } ] - }; - }; - - it('marks references unreachable in multi-level downstream after edge deletion', () => { - const { nodes, edges } = chainNodesAndEdges(); - - const result = checkWorkflowNodeIssues({ - nodes, - edges, - referenceNodeIds: ['B', 'C', 'D'] }); - // B、C 引用的 A 仍存在,但已不在允许来源范围内 expect(result.B).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) @@ -754,136 +720,50 @@ describe('checkWorkflowNodeIssues', () => { expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) ]) ); - // D 引用的 B 仍在来源集合中,但 B 已不可从入口到达 - expect(result.D).toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-b' }) - ]) - ); }); - it('does not report unreachable references without referenceNodeIds scope', () => { - const { nodes, edges } = chainNodesAndEdges(); - - const result = checkWorkflowNodeIssues({ nodes, edges }); - - ['B', 'C', 'D'].forEach((nodeId) => { - expect(result[nodeId]?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + it('distinguishes a missing source from an out-of-scope source', () => { + const sourceNode = makeOutputNode('source'); + const validNode = makeNode('valid', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-source', ['source', 'out'])] }); - }); - - it('only reports unreachable references for nodes inside the impact scope', () => { - const { nodes, edges } = chainNodesAndEdges(); - - const result = checkWorkflowNodeIssues({ - nodes, - edges, - referenceNodeIds: ['B'] - }); - - expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBe(true); - expect(result.C?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); - expect(result.D?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); - }); - - it('only rechecks the deleted branch, not sibling branches', () => { - const nodeA = makeOutputNode('A'); - const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-a', ['A', 'out'])] - }); - const nodeC = makeNode('C', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-a', ['A', 'out'])] + const invalidNode = makeNode('invalid', FlowNodeTypeEnum.answerNode, { + inputs: [makeReferenceInput('ref-ghost', ['ghost', 'out'])] }); const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeA, nodeB, nodeC], - // A -> B 已删除,A -> C 保留 + nodes: [startNode, sourceNode, validNode, invalidNode], edges: [ - { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, - { id: 'e-a-c', source: 'A', target: 'C', type: EDGE_TYPE } - ], - referenceNodeIds: ['B'] - }); - - expect(result.B).toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-a' }) - ]) - ); - expect(result.C?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); - }); - - it('keeps merge references valid when an alternative source remains reachable', () => { - const nodeA = makeOutputNode('A'); - const nodeB = makeOutputNode('B'); - const nodeC = makeOutputNode('C'); - const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { - inputs: [ - makeReferenceInput('ref-b', ['B', 'out']), - makeReferenceInput('ref-c', ['C', 'out']) + { id: 'e-start-source', source: 'start', target: 'source', type: EDGE_TYPE }, + { id: 'e-source-valid', source: 'source', target: 'valid', type: EDGE_TYPE }, + { id: 'e-start-invalid', source: 'start', target: 'invalid', type: EDGE_TYPE } ] }); - const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeA, nodeB, nodeC, nodeD], - // A -> B 已删除;C -> D 替代来源保留 - edges: [ - { id: 'e-start-a', source: 'start', target: 'A', type: EDGE_TYPE }, - { id: 'e-start-c', source: 'start', target: 'C', type: EDGE_TYPE }, - { id: 'e-b-d', source: 'B', target: 'D', type: EDGE_TYPE }, - { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } - ], - referenceNodeIds: ['B', 'D'] - }); - - // D 引用的 B 仍存在但不再可达;引用的 C 仍可通过替代路径获取 - expect(result.D).toEqual( - expect.arrayContaining([ - expect.objectContaining({ code: 'unreachable_reference', inputKey: 'ref-b' }) - ]) - ); - expect( - result.D?.some( - (issue) => issue.code !== 'required_input_empty' && issue.inputKey === 'ref-c' - ) - ).toBeFalsy(); - }); - - it('keeps invalid_reference priority when the source itself is gone', () => { - const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { - inputs: [makeReferenceInput('ref-ghost', ['ghost', 'out'])] - }); - - const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeB], - edges: [{ id: 'e-start-b', source: 'start', target: 'B', type: EDGE_TYPE }], - referenceNodeIds: ['B'] - }); - - expect(result.B).toEqual( + expect(result.valid?.some((issue) => issue.code.includes('reference'))).toBeFalsy(); + expect(result.invalid).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'invalid_reference', inputKey: 'ref-ghost' }) ]) ); - expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + expect(result.invalid?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); }); - it('never treats global variable references as unreachable', () => { - const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode, { + it('does not treat global variable references as unreachable', () => { + const node = makeNode('consumer', FlowNodeTypeEnum.answerNode, { inputs: [makeReferenceInput('ref-var', [VARIABLE_NODE_ID, 'var1'])] }); const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeB], - edges: [{ id: 'e-start-b', source: 'start', target: 'B', type: EDGE_TYPE }], - chatConfig: { variables: [{ key: 'var1' }] } as any, - referenceNodeIds: ['B'] + nodes: [startNode, node], + edges: [{ id: 'e-start-consumer', source: 'start', target: 'consumer', type: EDGE_TYPE }], + chatConfig: { variables: [{ key: 'var1' }] } as any }); - expect(result.B?.some((issue) => issue.code === 'unreachable_reference')).toBeFalsy(); + expect(result.consumer?.some((issue) => issue.code.includes('reference'))).toBeFalsy(); }); - it('reports a multi-select reference when any item becomes unreachable', () => { + it('reports a multi-select reference when one item is out of scope', () => { const nodeB = makeOutputNode('B'); const nodeC = makeOutputNode('C'); const nodeD = makeNode('D', FlowNodeTypeEnum.answerNode, { @@ -903,13 +783,10 @@ describe('checkWorkflowNodeIssues', () => { const result = checkWorkflowNodeIssues({ nodes: [startNode, nodeB, nodeC, nodeD], - // B 不可达,C 仍可达 edges: [ { id: 'e-start-c', source: 'start', target: 'C', type: EDGE_TYPE }, - { id: 'e-b-d', source: 'B', target: 'D', type: EDGE_TYPE }, { id: 'e-c-d', source: 'C', target: 'D', type: EDGE_TYPE } - ], - referenceNodeIds: ['B', 'D'] + ] }); expect(result.D).toEqual( @@ -919,9 +796,9 @@ describe('checkWorkflowNodeIssues', () => { ); }); - it('reports unreachable VariableUpdate value references by row field', () => { - const nodeB = makeOutputNode('B'); - const nodeVU = makeNode('variable-update', FlowNodeTypeEnum.variableUpdate, { + it('checks VariableUpdate reference values independently', () => { + const sourceNode = makeOutputNode('source'); + const variableUpdateNode = makeNode('variable-update', FlowNodeTypeEnum.variableUpdate, { inputs: [ { key: NodeInputKeyEnum.updateList, @@ -931,7 +808,7 @@ describe('checkWorkflowNodeIssues', () => { variable: [VARIABLE_NODE_ID, 'var1'], valueType: WorkflowIOValueTypeEnum.string, renderType: FlowNodeInputTypeEnum.reference, - value: [['B', 'out']] + value: [['source', 'out']] } ] } @@ -939,13 +816,14 @@ describe('checkWorkflowNodeIssues', () => { }); const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeB, nodeVU], - // B 不可达 - edges: [{ id: 'e-b-vu', source: 'B', target: 'variable-update', type: EDGE_TYPE }], + nodes: [startNode, sourceNode, variableUpdateNode], + edges: [ + { id: 'e-start-source', source: 'start', target: 'source', type: EDGE_TYPE }, + { id: 'e-start-vu', source: 'start', target: 'variable-update', type: EDGE_TYPE } + ], chatConfig: { variables: [{ key: 'var1', valueType: WorkflowIOValueTypeEnum.string }] - } as any, - referenceNodeIds: ['B', 'variable-update'] + } as any }); expect(result['variable-update']).toEqual( @@ -957,24 +835,6 @@ describe('checkWorkflowNodeIssues', () => { ]) ); }); - - it('adds no reference issues for impact nodes without broken references', () => { - const nodeB = makeNode('B', FlowNodeTypeEnum.answerNode); - - const result = checkWorkflowNodeIssues({ - nodes: [startNode, nodeB], - edges: [], - referenceNodeIds: ['B'] - }); - - expect( - result.B?.some((issue) => - ['invalid_reference', 'invalid_reference_type', 'unreachable_reference'].includes( - issue.code - ) - ) - ).toBeFalsy(); - }); }); it('preserves invalid single and multiple references when storing workflow data', () => { @@ -3369,7 +3229,7 @@ describe('getNodeAllSource', () => { ]); }); - it('keeps same-level upstream references before parent references after filtering empty outputs', () => { + it('keeps source nodes unique after filtering empty outputs', () => { const nodes = [ makeNode('variableUpdate', 'loop'), makeNodeWithoutOutputs('reply', 'loop'), @@ -3386,8 +3246,8 @@ describe('getNodeAllSource', () => { ] as Edge[]; expect(getSelectableSourceNodeIds({ nodeId: 'variableUpdate', nodes, edges })).toEqual([ - 'loopStart', 'textConcat', + 'loopStart', 'pluginStart', VARIABLE_NODE_ID ]); From 85794ae00ba9f4f8bca8e91d67ef9f28581f360c Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 31 Aug 2026 16:37:34 +0800 Subject: [PATCH 06/18] fix(workflow): unify reference checks --- .../workflow/migration/legacy/workflow.ts | 10 +- .../core/workflow/migration/schema.test.ts | 41 +++- .../Flow/hooks/useDebugInput.ts | 6 +- .../RenderInput/templates/Reference.tsx | 2 +- .../context/workflowActionsContext.tsx | 24 +- .../src/web/core/workflow/referenceCheck.ts | 158 +++++++++++++ projects/app/src/web/core/workflow/utils.ts | 164 ++++---------- .../src/web/core/workflow/workflowCheck.ts | 154 +++++++------ .../test/web/core/app/workflow/utils.test.ts | 209 +++++++++++++++++- 9 files changed, 560 insertions(+), 208 deletions(-) create mode 100644 projects/app/src/web/core/workflow/referenceCheck.ts diff --git a/packages/global/core/workflow/migration/legacy/workflow.ts b/packages/global/core/workflow/migration/legacy/workflow.ts index ec096c1eea5e..ef2725513c85 100644 --- a/packages/global/core/workflow/migration/legacy/workflow.ts +++ b/packages/global/core/workflow/migration/legacy/workflow.ts @@ -45,8 +45,9 @@ const cleanReferenceValue = (value: unknown, isArray: boolean) => { } if (isReferenceItem(value)) return value; - if (Array.isArray(value) && value.length === 1 && isReferenceItem(value[0])) { - return value[0]; + if (Array.isArray(value)) { + const referenceItems = value.filter(isReferenceItem); + if (referenceItems.length === 1) return referenceItems[0]; } return isEmptyReferenceValue(value) ? value : undefined; }; @@ -132,7 +133,10 @@ const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { if (variable === undefined) delete nextItem.variable; else nextItem.variable = variable; if (nextItem.renderType === FlowNodeInputTypeEnum.reference) { - const value = cleanReferenceValue(nextItem.value, true); + const value = cleanReferenceValue( + nextItem.value, + typeof nextItem.valueType === 'string' && nextItem.valueType.startsWith('array') + ); if (value === undefined) delete nextItem.value; else nextItem.value = value; } diff --git a/packages/global/test/core/workflow/migration/schema.test.ts b/packages/global/test/core/workflow/migration/schema.test.ts index 2f12bc202bd1..c6c248a8c720 100644 --- a/packages/global/test/core/workflow/migration/schema.test.ts +++ b/packages/global/test/core/workflow/migration/schema.test.ts @@ -203,12 +203,51 @@ describe('workflow input schema boundaries', () => { const update = (variableUpdate.inputs[0].value as any)[0]; expect(update.variable).toEqual(['deleted-node', 'target']); - expect(update.value).toEqual([['deleted-node', 'value']]); + expect(update.value).toEqual(['deleted-node', 'value']); expect(update.valueReferenceSnapshots).toEqual([ { reference: ['deleted-node', 'value'], sourceLabel: 'Deleted node' } ]); expect(await migrateWorkflowToCurrent(result as any)).toEqual(result); }); + + it('keeps scalar VariableUpdate references scalar and wraps array references', async () => { + const result = await migrateWorkflowToCurrent({ + nodes: [ + { + nodeId: 'variable-update', + flowNodeType: 'variableUpdate', + name: 'Variable Update', + inputs: [ + { + key: NodeInputKeyEnum.updateList, + label: 'Update list', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: [ + { + variable: ['deleted-node', 'scalar-target'], + value: [['deleted-node', 'scalar-value']], + valueType: WorkflowIOValueTypeEnum.string, + renderType: FlowNodeInputTypeEnum.reference + }, + { + variable: ['deleted-node', 'array-target'], + value: ['deleted-node', 'array-value'], + valueType: WorkflowIOValueTypeEnum.arrayString, + renderType: FlowNodeInputTypeEnum.reference + } + ] + } + ], + outputs: [] + } + ], + edges: [] + } as any); + + const updateList = result.nodes[0]?.inputs[0]?.value as any[]; + expect(updateList[0].value).toEqual(['deleted-node', 'scalar-value']); + expect(updateList[1].value).toEqual([['deleted-node', 'array-value']]); + }); }); describe('workflow migration boundary', () => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebugInput.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebugInput.ts index fd83a7b59eb1..03c32c1bcd95 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebugInput.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebugInput.ts @@ -18,10 +18,8 @@ import type { FileSelectorValueItemType } from '@/components/core/app/FileSelect import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { VariableItemType } from '@fastgpt/global/core/app/type'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; -import { - workflowReferenceValueIsSelectable, - type WorkflowReferenceSourceNode -} from '@/web/core/workflow/utils'; +import type { WorkflowReferenceSourceNode } from '@/web/core/workflow/utils'; +import { workflowReferenceValueIsSelectable } from '@/web/core/workflow/referenceCheck'; const primitiveValueTypes = new Set([ WorkflowIOValueTypeEnum.string, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index f74cb1b3262f..3d5df07131a0 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -5,11 +5,11 @@ import MyIcon from '@fastgpt/web/components/common/Icon'; import { getNodeAllSource, getWorkflowReferenceItems, - getWorkflowReferenceStatus, isConfiguredReferenceValue, filterSelectableWorkflowNodeOutputs, type WorkflowReferenceSourceNode } from '@/web/core/workflow/utils'; +import { getWorkflowReferenceStatus } from '@/web/core/workflow/referenceCheck'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; 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 b8408ba9156c..a50a6916e069 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -295,8 +295,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod [onRefreshSingleNodeWorkflowCheckIssues] ); - /** 连线变更后防抖全量扫描,及时更新 no_upstream 等依赖连线的错误态。 */ - const scheduleWorkflowCheckOnEdgeChange = useCallback(() => { + /** 结构变化后防抖全量扫描,及时更新依赖节点关系和输出的引用错误态。 */ + const scheduleFullWorkflowCheck = useCallback(() => { if (edgeCheckTimerRef.current) { clearTimeout(edgeCheckTimerRef.current); } @@ -374,8 +374,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod } } - scheduleWorkflowCheckOnEdgeChange(); - }, [edges, scheduleWorkflowCheckOnEdgeChange, getNodes, setNodes]); + scheduleFullWorkflowCheck(); + }, [edges, scheduleFullWorkflowCheck, getNodes, setNodes]); useEffect(() => { const timers = singleNodeCheckTimerRef.current; @@ -423,11 +423,13 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod ...item.data, ...node, inputs: node.inputs.map((input) => { - const value = - item.data.inputs.find((i) => i.key === input.key)?.value ?? input.value; + const previousInput = item.data.inputs.find((i) => i.key === input.key); return { ...input, - value + value: previousInput?.value ?? input.value, + ...(previousInput?.referenceSnapshots === undefined + ? {} + : { referenceSnapshots: previousInput.referenceSnapshots }) }; }) } @@ -436,8 +438,10 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod return item; }) ); + + scheduleFullWorkflowCheck(); }, - [forbiddenSaveSnapshotRef, setNodes] + [forbiddenSaveSnapshotRef, scheduleFullWorkflowCheck, setNodes] ); // 使用结构共享优化的节点更改 @@ -609,7 +613,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }); if (updateData.length > 1 || shouldRunFullWorkflowCheck) { - scheduleWorkflowCheckOnEdgeChange(); + scheduleFullWorkflowCheck(); } else { nodeIdsToRecheck.forEach((nodeId) => scheduleSingleNodeWorkflowCheck(nodeId)); } @@ -621,7 +625,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod onDelEdge, llmModelMap, scheduleSingleNodeWorkflowCheck, - scheduleWorkflowCheckOnEdgeChange + scheduleFullWorkflowCheck ] ); diff --git a/projects/app/src/web/core/workflow/referenceCheck.ts b/projects/app/src/web/core/workflow/referenceCheck.ts new file mode 100644 index 000000000000..3406d8a05719 --- /dev/null +++ b/projects/app/src/web/core/workflow/referenceCheck.ts @@ -0,0 +1,158 @@ +import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import type { ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import type { AppChatConfigType } from '@fastgpt/global/core/app/type'; +import { + filterSelectableWorkflowNodeOutputs, + getWorkflowGlobalVariables, + getWorkflowReferenceItems, + isConfiguredReferenceValue, + isWorkflowReferenceItem, + type WorkflowReferenceSourceNode, + workflowValueTypeIsCompatible +} from './utils'; + +export type WorkflowReferenceStatusCode = + | 'empty' + | 'valid' + | 'invalid_reference' + | 'unreachable_reference' + | 'invalid_reference_type'; + +export type WorkflowReferenceStatus = { + code: WorkflowReferenceStatusCode; + sourceType?: WorkflowIOValueTypeEnum; +}; + +export type WorkflowReferenceIssueCode = Exclude; + +type GetWorkflowReferenceStatusProps = { + value: unknown; + valueType?: WorkflowIOValueTypeEnum; + sourceNodeIds?: Iterable; + sourceNodes?: WorkflowReferenceSourceNode[]; + getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; + chatConfig?: AppChatConfigType; +}; + +const isMalformedReferenceValue = (value: unknown) => { + if (!isConfiguredReferenceValue(value) || isWorkflowReferenceItem(value)) return false; + if (!Array.isArray(value)) return true; + return value.some((item) => !isWorkflowReferenceItem(item)); +}; + +/** + * 判断单项引用状态。输出可用性优先于来源范围,保证失效输出不会被误报为不可达。 + * 普通来源只在显式存在于 sourceNodeIds 时可用;global reference 单独按 chatConfig 查询。 + */ +export const getWorkflowReferenceStatus = ({ + value, + valueType, + sourceNodeIds, + sourceNodes, + getNodeById, + chatConfig +}: GetWorkflowReferenceStatusProps): WorkflowReferenceStatus => { + if (!isConfiguredReferenceValue(value)) return { code: 'empty' }; + if (!isWorkflowReferenceItem(value)) return { code: 'invalid_reference' }; + + const [sourceNodeId, outputId] = value; + const sourceNode = + sourceNodes?.find((node) => node.nodeId === sourceNodeId) ?? getNodeById(sourceNodeId); + + if (sourceNodeId === VARIABLE_NODE_ID) { + if (chatConfig !== undefined) { + const globalVariable = getWorkflowGlobalVariables({ chatConfig }).find( + (variable) => variable.key === outputId + ); + if (!globalVariable) return { code: 'invalid_reference' }; + if (!workflowValueTypeIsCompatible(globalVariable.valueType, valueType)) { + return { + code: 'invalid_reference_type', + sourceType: globalVariable.valueType + }; + } + return { code: 'valid', sourceType: globalVariable.valueType }; + } + + if (!sourceNode) return { code: 'valid' }; + } + + const sourceOutput = sourceNode?.outputs.find((output) => output.id === outputId); + if (!sourceNode || !sourceOutput) return { code: 'invalid_reference' }; + + const selectableOutput = filterSelectableWorkflowNodeOutputs({ + outputs: [sourceOutput], + valueType: WorkflowIOValueTypeEnum.any, + catchError: sourceNode.catchError + }); + if (!selectableOutput.length) { + return { code: 'invalid_reference', sourceType: sourceOutput.valueType }; + } + + if ( + sourceNodeId !== VARIABLE_NODE_ID && + sourceNodeIds && + !new Set(sourceNodeIds).has(sourceNodeId) + ) { + return { code: 'unreachable_reference', sourceType: sourceOutput.valueType }; + } + + if (!workflowValueTypeIsCompatible(sourceOutput.valueType, valueType)) { + return { code: 'invalid_reference_type', sourceType: sourceOutput.valueType }; + } + + return { code: 'valid', sourceType: sourceOutput.valueType }; +}; + +/** 将单选、多选和 malformed 值统一转换为引用状态,保留多选原顺序。 */ +export const getWorkflowReferenceStatuses = ({ + value, + ...props +}: GetWorkflowReferenceStatusProps): WorkflowReferenceStatus[] => { + const referenceItems = getWorkflowReferenceItems(value); + const statuses = referenceItems.map((item) => + getWorkflowReferenceStatus({ value: item, ...props }) + ); + + if ( + isMalformedReferenceValue(value) || + (statuses.length === 0 && isConfiguredReferenceValue(value)) + ) { + return [{ code: 'invalid_reference' }, ...statuses]; + } + + return statuses; +}; + +/** 将多项引用状态聚合为一个稳定的 checker issue code。 */ +export const getWorkflowReferenceIssueCode = ( + statuses: WorkflowReferenceStatus[] +): WorkflowReferenceIssueCode | undefined => + (['invalid_reference', 'unreachable_reference', 'invalid_reference_type'] as const).find((code) => + statuses.some((status) => status.code === code) + ); + +/** 判断引用是否仍可被调试输入或 selector 接受;历史失效项不会成为新值。 */ +export const workflowReferenceValueIsSelectable = ({ + value, + sourceNodes, + valueType, + chatConfig +}: { + value?: ReferenceValueType; + sourceNodes: WorkflowReferenceSourceNode[]; + valueType?: WorkflowIOValueTypeEnum; + chatConfig?: AppChatConfigType; +}) => + getWorkflowReferenceItems(value).some( + (item) => + getWorkflowReferenceStatus({ + value: item, + valueType, + sourceNodes, + sourceNodeIds: sourceNodes.map((node) => node.nodeId), + getNodeById: () => undefined, + chatConfig + }).code === 'valid' + ); diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index f9cbe22d0e85..2a262501f712 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -18,6 +18,7 @@ import { type EditorVariablePickerType } from '@fastgpt/web/components/common/Te import { formatEditorVariablePickerIcon, getAppChatConfig, + getHandleId, getSelectedInputRenderType, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; @@ -25,8 +26,7 @@ import { type TFunction } from 'next-i18next'; import { type FlowNodeInputItemType, type FlowNodeOutputItemType, - type ReferenceItemValueType, - type ReferenceValueType + type ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io'; import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; import { initNewIfElseList } from '@fastgpt/global/core/workflow/template/system/ifElse/utils'; @@ -428,6 +428,45 @@ export type WorkflowReferenceSourceNode = { catchError?: boolean; }; +/** 多分支节点仅允许当前配置仍存在的 source handle 参与来源计算和工作流检查。 */ +export const isWorkflowEdgeSourceHandleValid = ( + sourceNode: FlowNodeItemType | undefined, + sourceHandle: string | null | undefined +) => { + if (!sourceNode) return false; + + const { nodeId, flowNodeType, inputs } = sourceNode; + + if (flowNodeType === FlowNodeTypeEnum.userSelect) { + if (!sourceHandle) return false; + + const options = inputs?.find((input) => input.key === NodeInputKeyEnum.userSelectOptions) + ?.value as Array<{ key?: string }> | undefined; + + return ( + Array.isArray(options) && + options.some( + (option) => option.key && sourceHandle === getHandleId(nodeId, 'source', option.key) + ) + ); + } + + if (flowNodeType === FlowNodeTypeEnum.classifyQuestion) { + if (!sourceHandle) return false; + + const agents = inputs?.find((input) => input.key === NodeInputKeyEnum.agents)?.value as + | Array<{ key?: string }> + | undefined; + + return ( + Array.isArray(agents) && + agents.some((agent) => agent.key && sourceHandle === getHandleId(nodeId, 'source', agent.key)) + ); + } + + return true; +}; + /** * 过滤引用选择器中真正可选的输出。 * ReferenceSelector 和节点 debug 的引用有效性判断必须共用这套规则,避免已删除、类型不匹配、 @@ -443,11 +482,9 @@ export const filterSelectableWorkflowNodeOutputs = ({ catchError?: boolean; }) => { const selectableOutputs = outputs.filter((output) => { - if (output.type === FlowNodeOutputTypeEnum.error) { - return catchError === true; - } - - return output.id !== NodeOutputKeyEnum.addOutputParam && output.invalid !== true; + if (output.id === NodeOutputKeyEnum.addOutputParam || output.invalid === true) return false; + if (output.type === FlowNodeOutputTypeEnum.error) return catchError === true; + return true; }); return filterWorkflowNodeOutputsByType( @@ -456,7 +493,7 @@ export const filterSelectableWorkflowNodeOutputs = ({ ); }; -const isReferenceItem = (value: unknown): value is ReferenceItemValueType => +export const isWorkflowReferenceItem = (value: unknown): value is ReferenceItemValueType => Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && @@ -466,10 +503,10 @@ const isReferenceItem = (value: unknown): value is ReferenceItemValueType => /** 从 canonical 单选或多选值中按原顺序提取引用项。 */ export const getWorkflowReferenceItems = (value: unknown): ReferenceItemValueType[] => { - if (isReferenceItem(value)) return [value]; + if (isWorkflowReferenceItem(value)) return [value]; if (!Array.isArray(value)) return []; - return value.filter(isReferenceItem); + return value.filter(isWorkflowReferenceItem); }; export const isEmptyReferenceValue = (value: unknown) => @@ -485,111 +522,6 @@ export const isEmptyReferenceValue = (value: unknown) => /** 空引用不参与状态检查;其他非空值统一视为已配置并报告 invalid_reference。 */ export const isConfiguredReferenceValue = (value: unknown) => !isEmptyReferenceValue(value); -export type WorkflowReferenceStatusCode = - | 'empty' - | 'valid' - | 'invalid_reference' - | 'unreachable_reference' - | 'invalid_reference_type'; - -export type WorkflowReferenceStatus = { - code: WorkflowReferenceStatusCode; - sourceType?: WorkflowIOValueTypeEnum; -}; - -/** - * 判断单项引用的当前状态,统一处理来源、输出、可达范围和类型。 - * sourceNodeIds 只表达当前消费节点允许的普通来源;global reference 单独按 chatConfig 查询。 - */ -export const getWorkflowReferenceStatus = ({ - value, - valueType, - sourceNodeIds, - sourceNodes, - getNodeById, - chatConfig -}: { - value: unknown; - valueType?: WorkflowIOValueTypeEnum; - sourceNodeIds?: Iterable; - sourceNodes?: WorkflowReferenceSourceNode[]; - getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; - chatConfig?: AppChatConfigType; -}): WorkflowReferenceStatus => { - if (!isConfiguredReferenceValue(value)) return { code: 'empty' }; - if (!isReferenceItem(value)) return { code: 'invalid_reference' }; - - const [sourceNodeId, outputId] = value; - const sourceNode = - sourceNodes?.find((node) => node.nodeId === sourceNodeId) ?? getNodeById(sourceNodeId); - const sourceOutput = sourceNode?.outputs.find((output) => output.id === outputId); - - if (sourceNodeId === VARIABLE_NODE_ID && !sourceOutput) { - // 缺少 chatConfig 时无法判断全局变量是否存在,保持旧数据检查的未知可继续语义。 - if (chatConfig === undefined) return { code: 'valid' }; - - const globalVariable = getWorkflowGlobalVariables({ - chatConfig - }).find((variable) => variable.key === outputId); - if (!globalVariable) return { code: 'invalid_reference' }; - if (!workflowValueTypeIsCompatible(globalVariable.valueType, valueType)) { - return { - code: 'invalid_reference_type', - sourceType: globalVariable.valueType - }; - } - return { code: 'valid', sourceType: globalVariable.valueType }; - } - - if (!sourceNode || !sourceOutput) return { code: 'invalid_reference' }; - if ( - sourceNodeId !== VARIABLE_NODE_ID && - sourceNodeIds && - !new Set(sourceNodeIds).has(sourceNodeId) - ) { - return { code: 'unreachable_reference', sourceType: sourceOutput.valueType }; - } - - const selectableOutput = filterSelectableWorkflowNodeOutputs({ - outputs: [sourceOutput], - valueType: WorkflowIOValueTypeEnum.any, - catchError: sourceNode.catchError - }); - if (!selectableOutput.length) { - return { code: 'invalid_reference', sourceType: sourceOutput.valueType }; - } - if (!workflowValueTypeIsCompatible(sourceOutput.valueType, valueType)) { - return { code: 'invalid_reference_type', sourceType: sourceOutput.valueType }; - } - - return { code: 'valid', sourceType: sourceOutput.valueType }; -}; - -/** - * 判断引用值是否仍能被 ReferenceSelector 选中。 - * 单选引用要求当前二元组命中;多选引用只要存在一个仍可选的引用项,选择器就会展示有效值。 - */ -export const workflowReferenceValueIsSelectable = ({ - value, - sourceNodes, - valueType -}: { - value?: ReferenceValueType; - sourceNodes: WorkflowReferenceSourceNode[]; - valueType?: WorkflowIOValueTypeEnum; -}) => { - return getWorkflowReferenceItems(value).some( - (item) => - getWorkflowReferenceStatus({ - value: item, - valueType, - sourceNodes, - sourceNodeIds: sourceNodes.map((node) => node.nodeId), - getNodeById: () => undefined - }).code === 'valid' - ); -}; - /** * 获取当前节点可引用的普通来源 ID。 * 按当前节点到根容器的入边和 reference 输入遍历,visited 防止坏 parent 数据循环。 @@ -620,7 +552,7 @@ export const getNodeAllSourceIds = ({ searchedTargetNodeIds.add(targetNodeId); edges.forEach((edge) => { if (edge.target !== targetNodeId) return; - if (!getNodeById(edge.source)) return; + if (!isWorkflowEdgeSourceHandleValid(getNodeById(edge.source), edge.sourceHandle)) return; sourceIds.add(edge.source); queue.push(edge.source); }); diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index 3bd02dd3f100..85de0b0a709f 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -13,7 +13,7 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { getHandleId, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; +import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; import type { TFunction } from 'next-i18next'; import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { AppChatConfigType } from '@fastgpt/global/core/app/type'; @@ -36,12 +36,15 @@ import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; import { getNodeAllSourceIds, getRefData, - getWorkflowReferenceItems, - getWorkflowReferenceStatus, isConfiguredReferenceValue, isEmptyReferenceValue, + isWorkflowEdgeSourceHandleValid, workflowValueTypeIsCompatible } from './utils'; +import { + getWorkflowReferenceIssueCode as getReferenceIssueCode, + getWorkflowReferenceStatuses +} from './referenceCheck'; type WorkflowCheckContext = { nodeMap: Map>; @@ -57,48 +60,6 @@ const workflowCheckSkipConnectionTypes = new Set([ FlowNodeTypeEnum.emptyNode ]); -/** - * 多分支节点的 sourceHandle 必须与当前 options/agents 中的 key 一致; - * 删除分支后残留的悬空 edge 不应计入有效连线。 - */ -const isWorkflowEdgeSourceHandleValid = ( - sourceNode: Node | undefined, - sourceHandle: string | null | undefined -) => { - if (!sourceNode) return false; - - const { nodeId, flowNodeType, inputs } = sourceNode.data; - - if (flowNodeType === FlowNodeTypeEnum.userSelect) { - if (!sourceHandle) return false; - - const options = inputs?.find((input) => input.key === NodeInputKeyEnum.userSelectOptions) - ?.value as Array<{ key?: string }> | undefined; - - return ( - Array.isArray(options) && - options.some( - (option) => option.key && sourceHandle === getHandleId(nodeId, 'source', option.key) - ) - ); - } - - if (flowNodeType === FlowNodeTypeEnum.classifyQuestion) { - if (!sourceHandle) return false; - - const agents = inputs?.find((input) => input.key === NodeInputKeyEnum.agents)?.value as - | Array<{ key?: string }> - | undefined; - - return ( - Array.isArray(agents) && - agents.some((agent) => agent.key && sourceHandle === getHandleId(nodeId, 'source', agent.key)) - ); - } - - return true; -}; - const workflowCheckStartTypes = new Set([ FlowNodeTypeEnum.workflowStart, FlowNodeTypeEnum.pluginInput, @@ -361,7 +322,7 @@ const createWorkflowCheckContext = ({ edges.forEach((edge) => { const sourceNode = nodeMap.get(edge.source); - if (!isWorkflowEdgeSourceHandleValid(sourceNode, edge.sourceHandle)) { + if (!isWorkflowEdgeSourceHandleValid(sourceNode?.data, edge.sourceHandle)) { return; } @@ -408,29 +369,17 @@ const getReferenceStatuses = ({ sourceNodeIds: string[]; context: WorkflowCheckContext; }) => { - const referenceItems = getWorkflowReferenceItems(value); - if (referenceItems.length === 0) { - return isConfiguredReferenceValue(value) ? [{ code: 'invalid_reference' as const }] : []; - } - const getNodeById = (nodeId: string | null | undefined) => context.nodeMap.get(nodeId ?? '')?.data; - return referenceItems.map((item) => - getWorkflowReferenceStatus({ - value: item, - valueType, - sourceNodeIds, - getNodeById, - chatConfig: context.chatConfig - }) - ); + return getWorkflowReferenceStatuses({ + value, + valueType, + sourceNodeIds, + getNodeById, + chatConfig: context.chatConfig + }); }; -const getReferenceIssueCode = (statuses: ReturnType) => - ['invalid_reference', 'unreachable_reference', 'invalid_reference_type'].find((code) => - statuses.some((status) => status.code === code) - ); - const isEmptyReferenceInputValue = (value: unknown) => isEmptyReferenceValue(value); const isVariableUpdateValueEmpty = (item: TUpdateListItem) => { @@ -542,14 +491,21 @@ export const checkWorkflowNodeIssues = ({ | IfElseListItemType[] | undefined; const hasIncompleteCondition = (ifElseList ?? []).some((item) => - item.list.some( - (listItem) => - listItem.variable === undefined || + item.list.some((listItem) => { + const hasEmptyVariable = + listItem.variable === undefined || isEmptyReferenceValue(listItem.variable); + const hasEmptyValue = + listItem.value === undefined || + (listItem.valueType === 'reference' && isEmptyReferenceValue(listItem.value)); + + return ( + hasEmptyVariable || listItem.condition === undefined || - (listItem.value === undefined && + (hasEmptyValue && listItem.condition !== VariableConditionEnum.isEmpty && listItem.condition !== VariableConditionEnum.isNotEmpty) - ) + ); + }) ); if (!ifElseList || hasIncompleteCondition) { @@ -560,6 +516,64 @@ export const checkWorkflowNodeIssues = ({ inputKey: NodeInputKeyEnum.ifElseList }); } + + if (ifElseList) { + ifElseList.forEach((branch, branchIndex) => { + branch.list.forEach((condition, conditionIndex) => { + const addReferenceIssue = ({ + field, + code + }: { + field: 'variable' | 'value'; + code: 'invalid_reference' | 'invalid_reference_type' | 'unreachable_reference'; + }) => { + addIssue({ + node, + code, + message: getWorkflowCheckIssueMessage(code, t, { + inputName: field === 'variable' ? '变量' : '值' + }), + inputKey: `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].${field}` + }); + }; + + const variableStatuses = getReferenceStatuses({ + value: condition.variable, + sourceNodeIds, + context + }); + const variableIssueCode = getReferenceIssueCode(variableStatuses); + if (variableIssueCode) { + addReferenceIssue({ + field: 'variable', + code: variableIssueCode + }); + } + + const variableType = getRefData({ + variable: condition.variable, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + chatConfig: context.chatConfig + }).valueType; + + if (condition.valueType === 'reference') { + const valueStatuses = getReferenceStatuses({ + value: condition.value, + valueType: variableType, + sourceNodeIds, + context + }); + const valueIssueCode = getReferenceIssueCode(valueStatuses); + if (valueIssueCode) { + addReferenceIssue({ + field: 'value', + code: valueIssueCode + }); + } + } + }); + }); + } } if (data.flowNodeType === FlowNodeTypeEnum.userSelect) { 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 0c9596eb70b7..573ed6f05851 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -14,14 +14,21 @@ import { import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; +import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { nodeTemplate2FlowNode, storeNode2FlowNode, getNodeAllSource, filterWorkflowNodeOutputsByType, - filterSelectableWorkflowNodeOutputs, - workflowReferenceValueIsSelectable + filterSelectableWorkflowNodeOutputs } from '@/web/core/workflow/utils'; +import { workflowReferenceValueIsSelectable } from '@/web/core/workflow/referenceCheck'; +import { + getWorkflowReferenceIssueCode, + getWorkflowReferenceStatus, + getWorkflowReferenceStatuses +} from '@/web/core/workflow/referenceCheck'; import { checkWorkflowNodeIssues, checkWorkflowHasError, @@ -837,6 +844,106 @@ describe('checkWorkflowNodeIssues', () => { }); }); + it('checks references nested in IfElse conditions', () => { + const typedSourceNode = makeNode('typed-source', FlowNodeTypeEnum.workflowStart, { + outputs: [ + { + id: 'text', + key: 'text', + label: 'text', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + }, + { + id: 'count', + key: 'count', + label: 'count', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.number + } + ] + }); + const detachedSourceNode = makeNode('detached-source', FlowNodeTypeEnum.answerNode, { + outputs: [ + { + id: 'out', + key: 'out', + label: 'out', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + const ifElseNode = makeNode('if-else', FlowNodeTypeEnum.ifElseNode, { + inputs: [ + { + key: NodeInputKeyEnum.ifElseList, + label: 'Conditions', + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [ + { + condition: 'AND', + list: [ + { + variable: ['typed-source', 'missing'], + condition: VariableConditionEnum.equalTo, + value: 'text', + valueType: 'input' + }, + { + variable: ['typed-source', 'text'], + condition: VariableConditionEnum.equalTo, + value: ['typed-source', 'missing'], + valueType: 'reference' + }, + { + variable: ['typed-source', 'text'], + condition: VariableConditionEnum.equalTo, + value: ['typed-source', 'count'], + valueType: 'reference' + }, + { + variable: ['detached-source', 'out'], + condition: VariableConditionEnum.equalTo, + value: 'text', + valueType: 'input' + } + ] + } + ] + } + ] + }); + + const result = checkWorkflowNodeIssues({ + nodes: [typedSourceNode, detachedSourceNode, ifElseNode], + edges: [ + { id: 'e-source-if-else', source: 'typed-source', target: 'if-else', type: EDGE_TYPE } + ] + }); + + expect(result['if-else']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_reference', + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[0].variable` + }), + expect.objectContaining({ + code: 'invalid_reference', + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[1].value` + }), + expect.objectContaining({ + code: 'invalid_reference_type', + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[2].value` + }), + expect.objectContaining({ + code: 'unreachable_reference', + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[3].variable` + }) + ]) + ); + }); + it('preserves invalid single and multiple references when storing workflow data', () => { const sourceNode = makeNode('source', FlowNodeTypeEnum.workflowStart, { outputs: [ @@ -2990,7 +3097,11 @@ describe('filterSelectableWorkflowNodeOutputs', () => { makeOutput('count', WorkflowIOValueTypeEnum.number), makeOutput(NodeOutputKeyEnum.addOutputParam, WorkflowIOValueTypeEnum.string), makeOutput('invalid', WorkflowIOValueTypeEnum.string, { invalid: true }), - makeOutput('error', WorkflowIOValueTypeEnum.string, { type: FlowNodeOutputTypeEnum.error }) + makeOutput('error', WorkflowIOValueTypeEnum.string, { type: FlowNodeOutputTypeEnum.error }), + makeOutput('invalid-error', WorkflowIOValueTypeEnum.string, { + type: FlowNodeOutputTypeEnum.error, + invalid: true + }) ]; const result = filterSelectableWorkflowNodeOutputs({ @@ -3018,6 +3129,65 @@ describe('filterSelectableWorkflowNodeOutputs', () => { }); }); +describe('workflow reference status', () => { + const sourceNode = { + nodeId: 'source', + outputs: [ + { + id: 'invalid', + key: 'invalid', + label: 'invalid', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string, + invalid: true + }, + { + id: 'error', + key: 'error', + label: 'error', + type: FlowNodeOutputTypeEnum.error, + valueType: WorkflowIOValueTypeEnum.string, + invalid: true + } + ], + catchError: true + }; + + it('reports invalid output before unreachable source', () => { + expect( + getWorkflowReferenceStatus({ + value: ['source', 'invalid'], + sourceNodeIds: [], + getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) + }).code + ).toBe('invalid_reference'); + }); + + it('does not make invalid error output selectable when catchError is enabled', () => { + expect( + getWorkflowReferenceStatus({ + value: ['source', 'error'], + sourceNodeIds: ['source'], + getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) + }).code + ).toBe('invalid_reference'); + }); + + it('reports malformed items in mixed multiple references', () => { + const statuses = getWorkflowReferenceStatuses({ + value: [['source', 'error'], 'malformed'], + sourceNodeIds: ['source'], + getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) + }); + + expect(statuses.map((status) => status.code)).toEqual([ + 'invalid_reference', + 'invalid_reference' + ]); + expect(getWorkflowReferenceIssueCode(statuses)).toBe('invalid_reference'); + }); +}); + describe('workflowReferenceValueIsSelectable', () => { const sourceNodes = [ { @@ -3252,6 +3422,39 @@ describe('getNodeAllSource', () => { VARIABLE_NODE_ID ]); }); + + it('ignores edges from deleted UserSelect options when collecting sources', () => { + const source = makeNode('source'); + const selector = { + ...makeNode('selector'), + flowNodeType: FlowNodeTypeEnum.userSelect, + inputs: [ + { + key: NodeInputKeyEnum.userSelectOptions, + label: 'Options', + renderTypeList: [FlowNodeInputTypeEnum.custom], + value: [{ key: 'kept', value: 'Kept' }] + } + ] + } as FlowNodeItemType; + const target = makeNode('target'); + + expect( + getSourceNodeIds({ + nodeId: 'target', + nodes: [source, selector, target], + edges: [ + { + id: 'deleted-option-edge', + source: 'selector', + target: 'target', + sourceHandle: getHandleId('selector', 'source', 'deleted'), + targetHandle: 'target' + } + ] as Edge[] + }) + ).toEqual([VARIABLE_NODE_ID]); + }); }); describe('checkWorkflowBeforeRunOrPublish', () => { From d50fe22a1ca7bdbeca980c9497c9589fd5e98e8a Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 11:26:21 +0800 Subject: [PATCH 07/18] test: update tests Signed-off-by: Nixieboluo --- .../detail/WorkflowComponents/utils.test.ts | 41 ------------------- .../test/web/core/app/workflow/utils.test.ts | 34 ++++++++++----- 2 files changed, 24 insertions(+), 51 deletions(-) diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index 9e29cc61cd2e..def74d248ec7 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -673,47 +673,6 @@ describe('WorkflowComponents utils', () => { expect(result.nodes[0].inputs[0].value).toEqual(['childNode', 'result']); }); - it('should remove code node custom input references to its own tool params', () => { - const nodes = [ - { - data: { - nodeId: 'codeNode', - name: 'Code', - intro: '', - avatar: '', - flowNodeType: FlowNodeTypeEnum.code, - showStatus: true, - inputs: [ - { - key: 'arg1', - label: 'arg1', - canEdit: true, - defaultToAgentGenerated: true, - renderTypeList: [FlowNodeInputTypeEnum.addInputParam], - valueType: WorkflowIOValueTypeEnum.string - }, - { - key: 'customInput', - label: 'customInput', - canEdit: true, - renderTypeList: [FlowNodeInputTypeEnum.reference], - valueType: WorkflowIOValueTypeEnum.any, - value: ['codeNode', 'arg1'] - } - ], - outputs: [] - }, - position: { x: 0, y: 0 } - } - ]; - - const result = uiWorkflow2StoreWorkflow({ nodes, edges: [] }); - - expect(result.nodes[0].inputs.find((input) => input.key === 'customInput')?.value).toBe( - undefined - ); - }); - it('should preserve a canonical input selection when saving workflow', () => { const nodes = [ { 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 5cce76fbb2ed..798996a28222 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -11,7 +11,10 @@ import { FlowNodeOutputTypeEnum, EDGE_TYPE } from '@fastgpt/global/core/workflow/node/constant'; -import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { + VariableInputEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; @@ -111,6 +114,17 @@ describe('nodeTemplate2FlowNode', () => { }); describe('checkWorkflowNodeIssues', () => { + const makeChatVariable = ( + key: string, + valueType: WorkflowIOValueTypeEnum = WorkflowIOValueTypeEnum.string + ) => ({ + key, + label: key, + description: '', + type: VariableInputEnum.input, + valueType + }); + const makeNode = ( nodeId: string, flowNodeType: FlowNodeTypeEnum, @@ -308,7 +322,7 @@ describe('checkWorkflowNodeIssues', () => { nodes: [sourceNode, node], edges: [{ id: 'e1', source: 'source', target: 'ref', type: EDGE_TYPE }], chatConfig: { - variables: [{ key: 'kept-variable' }] + variables: [makeChatVariable('kept-variable')] } as any }); @@ -488,7 +502,7 @@ describe('checkWorkflowNodeIssues', () => { nodes: [typedSourceNode, node], edges: consumerEdge, chatConfig: { - variables: [{ key: 'num-var', valueType: WorkflowIOValueTypeEnum.number }] + variables: [makeChatVariable('num-var', WorkflowIOValueTypeEnum.number)] } as any }); @@ -566,7 +580,7 @@ describe('checkWorkflowNodeIssues', () => { nodes: [typedSourceNode, node], edges: consumerEdge, chatConfig: { - variables: [{ key: 'str-var', valueType: WorkflowIOValueTypeEnum.string }] + variables: [makeChatVariable('str-var')] } as any }); @@ -594,7 +608,7 @@ describe('checkWorkflowNodeIssues', () => { nodes: [typedSourceNode, node], edges: consumerEdge, chatConfig: { - variables: [{ key: 'str-var', valueType: WorkflowIOValueTypeEnum.string }] + variables: [makeChatVariable('str-var')] } as any }); @@ -616,7 +630,7 @@ describe('checkWorkflowNodeIssues', () => { nodes: [typedSourceNode, node], edges: consumerEdge, chatConfig: { - variables: [{ key: 'now-string', valueType: WorkflowIOValueTypeEnum.string }] + variables: [makeChatVariable('now-string')] } as any }); @@ -652,8 +666,8 @@ describe('checkWorkflowNodeIssues', () => { edges: consumerEdge, chatConfig: { variables: [ - { key: 'arr-var', valueType: WorkflowIOValueTypeEnum.arrayString }, - { key: 'any-var', valueType: WorkflowIOValueTypeEnum.any } + makeChatVariable('arr-var', WorkflowIOValueTypeEnum.arrayString), + makeChatVariable('any-var', WorkflowIOValueTypeEnum.any) ] } as any }); @@ -767,7 +781,7 @@ describe('checkWorkflowNodeIssues', () => { const result = checkWorkflowNodeIssues({ nodes: [startNode, node], edges: [{ id: 'e-start-consumer', source: 'start', target: 'consumer', type: EDGE_TYPE }], - chatConfig: { variables: [{ key: 'var1' }] } as any + chatConfig: { variables: [makeChatVariable('var1')] } as any }); expect(result.consumer?.some((issue) => issue.code.includes('reference'))).toBeFalsy(); @@ -832,7 +846,7 @@ describe('checkWorkflowNodeIssues', () => { { id: 'e-start-vu', source: 'start', target: 'variable-update', type: EDGE_TYPE } ], chatConfig: { - variables: [{ key: 'var1', valueType: WorkflowIOValueTypeEnum.string }] + variables: [makeChatVariable('var1')] } as any }); From c17c8f648afcedef46d0f1dad01c3496a49b9096 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 11:45:36 +0800 Subject: [PATCH 08/18] fix(workflow): handle invalid references safely Preserve invalid references for editor recovery while excluding\nunavailable values from runtime HTTP requests. --- .../core/workflow/dispatch/tools/http468.ts | 92 ++++++++- .../workflow/dispatch/tools/http468.test.ts | 191 +++++++++++++++++- .../common/MySelect/MultipleRowSelect.tsx | 11 +- .../context/workflowActionsContext.tsx | 5 +- .../src/web/core/workflow/workflowCheck.ts | 25 ++- .../test/web/core/app/workflow/utils.test.ts | 51 ++++- 6 files changed, 353 insertions(+), 22 deletions(-) diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index 843b9d822bdf..659a8d187c5f 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -7,15 +7,21 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; +import type { ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io'; import type { DispatchNodeResultType, ModuleDispatchProps } from '../../types/runtime'; import { formatVariableValByType, getReferenceVariableValue, valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils'; +import { + isValidReferenceValueFormat, + nodeInputIsReference +} from '@fastgpt/global/core/workflow/utils'; import json5 from 'json5'; import { JSONPath } from 'jsonpath-plus'; import { getSecretValue } from '../../../../common/secret/utils'; @@ -92,6 +98,76 @@ type HttpResponse = DispatchNodeResultType< const UNDEFINED_SIGN = 'UNDEFINED_SIGN'; +const isHttpReferenceItem = (value: unknown): value is ReferenceItemValueType => + isValidReferenceValueFormat(value) && + value[0].length > 0 && + typeof value[1] === 'string' && + value[1].length > 0; + +const getHttpReferenceItems = (value: unknown): ReferenceItemValueType[] => { + if (isHttpReferenceItem(value)) return [value]; + if (!Array.isArray(value)) return []; + + return value.filter(isHttpReferenceItem); +}; + +/** + * HTTP 节点请求前过滤引用参数。失效引用只影响当前请求,不修改节点保存值,保证编辑器仍可展示历史快照。 + * 多选引用保留有效项;全部引用失效时移除整个参数。非引用参数保持原值,包含 Agent 生成的普通参数。 + */ +export const filterHttpRuntimeParams = ({ + params, + node, + runtimeNodesMap, + variables +}: { + params: Record; + node: Pick; + runtimeNodesMap: Map; + variables: Record; +}) => { + const inputMap = new Map(node.inputs.map((input) => [input.key, input])); + + const isReferenceAvailable = ([sourceNodeId, outputId]: ReferenceItemValueType) => { + if (sourceNodeId === VARIABLE_NODE_ID) { + return ( + Object.prototype.hasOwnProperty.call(variables, outputId) && + variables[outputId] !== undefined + ); + } + + const sourceNode = runtimeNodesMap.get(sourceNodeId); + const output = sourceNode?.outputs.find((item) => item.id === outputId); + if (!sourceNode || !output || output.invalid === true || output.value === undefined) { + return false; + } + + return output.type !== FlowNodeOutputTypeEnum.error || sourceNode.catchError === true; + }; + + return Object.fromEntries( + Object.entries(params).flatMap(([key, value]) => { + if (key === NodeInputKeyEnum.addInputParam) return []; + + const input = inputMap.get(key); + if (!input || !nodeInputIsReference(input)) return [[key, value]]; + + const referenceItems = getHttpReferenceItems(input.value); + const validReferenceItems = referenceItems.filter(isReferenceAvailable); + if (validReferenceItems.length === 0) return []; + if (validReferenceItems.length === referenceItems.length) return [[key, value]]; + + const resolvedValue = getReferenceVariableValue({ + value: validReferenceItems, + nodesMap: runtimeNodesMap, + variables + }); + const formattedValue = valueTypeFormat(resolvedValue, input.valueType); + return formattedValue === undefined ? [] : [[key, formattedValue]]; + }) + ); +}; + export const dispatchHttp468Request = async (props: HttpRequestProps): Promise => { const { runningAppInfo, @@ -121,6 +197,13 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise = { - ...concatVariables, - [NodeInputKeyEnum.addInputParam]: concatVariables - }; + const allVariables: Record = concatVariables; // General data for variable substitution(Exclude: json body) const replaceStringVariables = (text: string) => { diff --git a/packages/service/test/core/workflow/dispatch/tools/http468.test.ts b/packages/service/test/core/workflow/dispatch/tools/http468.test.ts index 96d34139c7ca..536a6db16f99 100644 --- a/packages/service/test/core/workflow/dispatch/tools/http468.test.ts +++ b/packages/service/test/core/workflow/dispatch/tools/http468.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; -import { ContentTypes, NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { + ContentTypes, + NodeInputKeyEnum, + VARIABLE_NODE_ID, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { + FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; const axiosMock = vi.hoisted(() => vi.fn()); const isInternalAddressMock = vi.hoisted(() => vi.fn()); @@ -17,7 +26,10 @@ vi.mock('@fastgpt/service/common/system/utils', async (importOriginal) => { }; }); -import { dispatchHttp468Request } from '@fastgpt/service/core/workflow/dispatch/tools/http468'; +import { + dispatchHttp468Request, + filterHttpRuntimeParams +} from '@fastgpt/service/core/workflow/dispatch/tools/http468'; const buildProps = (httpContentType: ContentTypes) => ({ @@ -31,6 +43,7 @@ const buildProps = (httpContentType: ContentTypes) => toRuntimeRecord: () => ({ emptyKey: '' }) }, node: { + inputs: [], outputs: [] }, runtimeNodesMap: new Map(), @@ -79,3 +92,177 @@ describe('dispatchHttp468Request empty keys', () => { } ); }); + +describe('filterHttpRuntimeParams', () => { + it('removes deprecated containers and invalid references while preserving usable params', () => { + const node = { + inputs: [ + { + key: 'validReference', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['source', 'value'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'missingSource', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['missing', 'value'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'missingOutput', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['source', 'missing'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'invalidOutput', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['source', 'invalid'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'mixedReferences', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: [ + ['source', 'value'], + ['missing', 'value'] + ], + valueType: WorkflowIOValueTypeEnum.arrayString + }, + { + key: 'emptyReference', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: [['missing', 'value']], + valueType: WorkflowIOValueTypeEnum.arrayString + }, + { + key: 'uncaughtError', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['source', 'error'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'caughtError', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['caught-source', 'error'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'agentParam', + renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], + selectedType: FlowNodeInputTypeEnum.agentGenerated, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any; + const sourceNode = { + nodeId: 'source', + outputs: [ + { + id: 'value', + key: 'value', + type: FlowNodeOutputTypeEnum.static, + value: 'source value', + valueType: WorkflowIOValueTypeEnum.string + }, + { + id: 'invalid', + key: 'invalid', + type: FlowNodeOutputTypeEnum.static, + value: 'should not be sent', + invalid: true, + valueType: WorkflowIOValueTypeEnum.string + }, + { + id: 'error', + key: 'error', + type: FlowNodeOutputTypeEnum.error, + value: 'uncaught error', + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any; + const caughtSourceNode = { + nodeId: 'caught-source', + catchError: true, + outputs: [ + { + id: 'error', + key: 'error', + type: FlowNodeOutputTypeEnum.error, + value: 'caught error', + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any; + + const result = filterHttpRuntimeParams({ + params: { + [NodeInputKeyEnum.addInputParam]: { legacy: 'value' }, + validReference: 'source value', + missingSource: ['missing', 'value'], + missingOutput: undefined, + invalidOutput: 'should not be sent', + mixedReferences: ['source value', ['missing', 'value']], + emptyReference: [], + uncaughtError: 'uncaught error', + caughtError: 'caught error', + agentParam: 'generated value', + ordinaryParam: '' + }, + node, + runtimeNodesMap: new Map([ + ['source', sourceNode], + ['caught-source', caughtSourceNode] + ]), + variables: { [VARIABLE_NODE_ID]: 'unused', existing: 'global value' } + }); + + expect(result).toEqual({ + validReference: 'source value', + mixedReferences: ['source value'], + caughtError: 'caught error', + agentParam: 'generated value', + ordinaryParam: '' + }); + }); + + it('keeps valid global references and removes missing global values', () => { + const node = { + inputs: [ + { + key: 'validGlobal', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: [VARIABLE_NODE_ID, 'existing'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'missingGlobal', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: [VARIABLE_NODE_ID, 'missing'], + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any; + + expect( + filterHttpRuntimeParams({ + params: { validGlobal: 'global value', missingGlobal: undefined }, + node, + runtimeNodesMap: new Map(), + variables: { existing: 'global value' } + }) + ).toEqual({ validGlobal: 'global value' }); + }); +}); diff --git a/packages/web/components/common/MySelect/MultipleRowSelect.tsx b/packages/web/components/common/MySelect/MultipleRowSelect.tsx index 46a70cbad84b..176b0c60110f 100644 --- a/packages/web/components/common/MySelect/MultipleRowSelect.tsx +++ b/packages/web/components/common/MySelect/MultipleRowSelect.tsx @@ -318,15 +318,10 @@ export const MultipleRowArraySelect = ({ }); const onChange = useCallback( (val: any[][]) => { - // Filter invalid value - const validList = val.filter((item) => { - const listItem = list.find((v) => v.value === item[0]); - if (!listItem) return false; - return listItem.children?.some((v) => v.value === item[1]); - }); - onSelect(validList); + // 保留当前列表中已失效的引用,交给上层展示并在用户删除时移除。 + onSelect(val); }, - [list, onSelect] + [onSelect] ); const RenderList = useCallback( 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 95431dfd0d2b..37d4dd998ce7 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -604,6 +604,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod // - catchError 决定该节点的 error 输出是否可被引用; // - 输出的增删改直接影响下游已选引用(addOutput 本身不会让旧引用失效, // 但会触发 invalidCondition 重算,可能把兄弟输出置为 invalid); + // - aiModel 变化会影响 aiChat 的 reasoningText 输出是否可被引用; // - delOutput/replaceOutput 还会删边,与 edges effect 的全量检查重复, // 但两者共用同一个防抖 timer,最终只会扫描一次。 const shouldRunFullWorkflowCheck = updateData.some((item) => { @@ -621,7 +622,9 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod } if (item.type === 'updateInput' || item.type === 'replaceInput') { return ( - item.key === NodeInputKeyEnum.childrenNodeIdList || nodeInputIsReference(item.value) + item.key === NodeInputKeyEnum.aiModel || + item.key === NodeInputKeyEnum.childrenNodeIdList || + nodeInputIsReference(item.value) ); } return ['updateOutput', 'replaceOutput', 'addOutput', 'delOutput'].includes(item.type); diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index ff68cb122957..ee4b3e8964f7 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -51,6 +51,7 @@ type WorkflowCheckContext = { incomingEdgesMap: Map[]>; outgoingEdgesMap: Map[]>; reachableNodeSet: Set; + childrenNodeIdListMap: Record; chatConfig?: AppChatConfigType; }; @@ -313,11 +314,19 @@ const createWorkflowCheckContext = ({ const nodeMap = new Map>(); const incomingEdgesMap = new Map[]>(); const outgoingEdgesMap = new Map[]>(); + const childrenNodeIdListMap: Record = {}; nodes.forEach((node) => { nodeMap.set(node.data.nodeId, node); incomingEdgesMap.set(node.data.nodeId, []); outgoingEdgesMap.set(node.data.nodeId, []); + + if (node.data.parentNodeId) { + childrenNodeIdListMap[node.data.parentNodeId] = [ + ...(childrenNodeIdListMap[node.data.parentNodeId] ?? []), + node.data.nodeId + ]; + } }); edges.forEach((edge) => { @@ -354,6 +363,7 @@ const createWorkflowCheckContext = ({ incomingEdgesMap, outgoingEdgesMap, reachableNodeSet, + childrenNodeIdListMap, chatConfig }; }; @@ -448,6 +458,19 @@ export const checkWorkflowNodeIssues = ({ getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, edges }); + const getInputSourceNodeIds = (input: FlowNodeInputItemType) => { + if (data.flowNodeType !== FlowNodeTypeEnum.httpRequest468 || input.canEdit !== true) { + return sourceNodeIds; + } + + return getNodeAllSourceIds({ + nodeId: data.nodeId, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + edges, + includeChildren: true, + childrenNodeIdListMap: context.childrenNodeIdListMap + }); + }; const isToolNode = context.incomingEdgesMap .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); @@ -879,7 +902,7 @@ export const checkWorkflowNodeIssues = ({ getReferenceStatuses({ value: effectiveInputValue, valueType: input.valueType, - sourceNodeIds, + sourceNodeIds: getInputSourceNodeIds(input), context }) ) 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 798996a28222..27a68cd61067 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -44,10 +44,7 @@ import { collectWorkflowStartAutoFillRevertPatches, collectWorkflowStartOutputAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill'; -import type { - FlowNodeInputItemType, - FlowNodeOutputItemType -} from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io'; import { NodeOutputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants'; import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type'; import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; @@ -1542,6 +1539,52 @@ describe('checkWorkflowNodeIssues', () => { expect(result['http-ok']?.map((issue) => issue.code) ?? []).not.toContain('http_url_empty'); }); + it('checks HTTP dynamic references against its direct child outputs', () => { + const child = makeNode('http-child', FlowNodeTypeEnum.answerNode, { + parentNodeId: 'http-with-child', + outputs: [ + { + id: 'text', + key: 'text', + label: 'Text', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + const node = makeNodeWithTemplateInputs( + 'http-with-child', + FlowNodeTypeEnum.httpRequest468, + HttpNode468.inputs.map((input) => { + if (input.key === NodeInputKeyEnum.httpReqUrl) { + return { ...input, value: 'https://example.com/api' }; + } + if (input.key === NodeInputKeyEnum.addInputParam) { + return { ...input, value: {} }; + } + return input; + }) + ); + node.data.inputs.push({ + key: 'customVariable', + label: 'Custom variable', + canEdit: true, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: ['http-child', 'text'], + valueType: WorkflowIOValueTypeEnum.string + }); + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, node, child], + edges: [{ id: 'e-start-http', source: 'start', target: node.data.nodeId, type: EDGE_TYPE }] + }); + + expect( + result['http-with-child']?.filter((issue) => issue.inputKey === 'customVariable') ?? [] + ).toEqual([]); + }); + it('loopStart hidden required any does not false-positive required_input_empty', () => { const node = makeNodeWithTemplateInputs( 'loop-start', From 585a4430e5a4faabb96f207a37a34cdfba2b3521 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 14:54:26 +0800 Subject: [PATCH 09/18] refactor(workflow): simplify reference handling --- .../common/MySelect/MultipleRowSelect.tsx | 25 +- .../Flow/hooks/useDebug.tsx | 3 +- .../Flow/nodes/NodeDatasetConcat.tsx | 3 +- .../Flow/nodes/NodeIfElse/ListItem.tsx | 7 +- .../Flow/nodes/NodePluginIO/PluginOutput.tsx | 3 +- .../NodeVariableUpdate/VariableSelector.tsx | 3 +- .../templates/DynamicInputs/index.tsx | 3 +- .../RenderInput/templates/Reference.tsx | 576 ++++++++---------- .../context/workflowUtilsContext.tsx | 7 +- .../app/detail/WorkflowComponents/utils.ts | 5 +- .../src/web/core/workflow/referenceCheck.ts | 10 +- .../src/web/core/workflow/workflowCheck.ts | 207 +++---- .../test/web/core/app/workflow/utils.test.ts | 23 + 13 files changed, 372 insertions(+), 503 deletions(-) diff --git a/packages/web/components/common/MySelect/MultipleRowSelect.tsx b/packages/web/components/common/MySelect/MultipleRowSelect.tsx index 176b0c60110f..2024b1fee549 100644 --- a/packages/web/components/common/MySelect/MultipleRowSelect.tsx +++ b/packages/web/components/common/MySelect/MultipleRowSelect.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/refs */ -import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'; +import React, { useRef, useCallback, useState, useEffect } from 'react'; import { Button, useDisclosure, @@ -306,23 +306,10 @@ export const MultipleRowArraySelect = ({ const [navigationPath, setNavigationPath] = useState([]); - // Make sure the value is an array of arrays - const formatValue = useMemo(() => { - return Array.isArray(value) ? value.filter((v) => Array.isArray(v)) : []; - }, [value]); - - // Close when clicking outside useOutsideClick({ ref: ref, handler: onClose }); - const onChange = useCallback( - (val: any[][]) => { - // 保留当前列表中已失效的引用,交给上层展示并在用户删除时移除。 - onSelect(val); - }, - [onSelect] - ); const RenderList = useCallback( ({ index, list }: { index: number; list: MultipleSelectProps['list'] }) => { @@ -339,13 +326,13 @@ export const MultipleRowArraySelect = ({ setNavigationPath(newPath); } else { const parentValue = navigationPath[0]; - const newValues = [...formatValue]; + const newValues = [...value]; const newValue = [parentValue, item.value]; if (newValues.some((v) => v[0] === parentValue && v[1] === item.value)) { - onChange(newValues.filter((v) => !(v[0] === parentValue && v[1] === item.value))); + onSelect(newValues.filter((v) => !(v[0] === parentValue && v[1] === item.value))); } else { - onChange([...newValues, newValue]); + onSelect([...newValues, newValue]); } } }; @@ -366,7 +353,7 @@ export const MultipleRowArraySelect = ({ const showCheckbox = !hasChildren; const isChecked = showCheckbox && - formatValue.some((v) => v[1] === item.value && v[0] === navigationPath[0]); + value.some((v) => v[1] === item.value && v[0] === navigationPath[0]); return ( ); }, - [navigationPath, maxH, emptyTip, t, formatValue, onChange] + [navigationPath, maxH, emptyTip, t, value, onSelect] ); const onOpenSelect = useCallback(() => { 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 0d342fd6de49..235ec89c95ef 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 @@ -116,8 +116,7 @@ export const useDebug = () => { onRemoveError(); const storeNodes = uiWorkflow2StoreWorkflow({ nodes, - edges, - chatConfig: appDetail.chatConfig + edges }); return JSON.stringify(storeNodes); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx index 98bf4a965300..3faa83da53c3 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx @@ -154,7 +154,7 @@ const VariableSelector = ({ const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { referenceList, sourceNodeIds, sourceNodes } = useReference({ + const { referenceList, sourceNodes } = useReference({ nodeId, valueType: inputChildren.valueType }); @@ -207,7 +207,6 @@ const VariableSelector = ({ t('common:core.module.Dataset quote.select') )} list={referenceList} - sourceNodeIds={sourceNodeIds} sourceNodes={sourceNodes} valueType={inputChildren.valueType} referenceSnapshots={inputChildren.referenceSnapshots} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx index c0ba379688e1..9eb01b9bfc55 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx @@ -330,7 +330,7 @@ const VariableSelector = ({ }) => { const { t } = useTranslation(); - const { referenceList, sourceNodeIds, sourceNodes } = useReference({ + const { referenceList, sourceNodes } = useReference({ nodeId, valueType: WorkflowIOValueTypeEnum.any }); @@ -339,7 +339,6 @@ const VariableSelector = ({ { const { t } = useTranslation(); - const { referenceList, sourceNodeIds, sourceNodes } = useReference({ + const { referenceList, sourceNodes } = useReference({ nodeId, valueType }); @@ -27,7 +27,6 @@ const VariableSelector = ({ nodeId, variable, valueType, referenceSnapshots, onS - Array.isArray(value) && - typeof value[0] === 'string' && - value[0].length > 0 && - typeof value[1] === 'string' && - value[1].length > 0; - -const isSameReference = (left?: ReferenceItemValueType, right?: ReferenceItemValueType): boolean => - isConfiguredReferenceItem(left) && - isConfiguredReferenceItem(right) && - left[0] === right[0] && - left[1] === right[1]; - const getReferenceValueTitle = (value: unknown) => { if (Array.isArray(value)) { return value @@ -81,46 +68,20 @@ const getReferenceOutputKey = (value: unknown) => { return typeof value === 'string' ? value : undefined; }; -const getReferenceSnapshotFromList = ({ - value, - list -}: { - value: ReferenceItemValueType; - list: ReferenceSelectList; -}): WorkflowReferenceSnapshot | undefined => { - if (!isConfiguredReferenceItem(value)) return; +const getReferenceListItem = (value: unknown, list: ReferenceSelectList) => { + if (!isWorkflowReferenceItem(value)) return; const source = list.find((item) => item.value === value[0]); const output = source?.children.find((item) => item.value === value[1]); - if (!source || !output) return; - - return { - reference: value, - sourceLabel: source.sourceLabel, - outputLabel: output.outputLabel - }; + return source && output ? { source, output } : undefined; }; -const getReferenceSnapshotFromSource = ({ - value, - getNodeById -}: { - value: ReferenceItemValueType; - getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; -}): WorkflowReferenceSnapshot | undefined => { - if (!isConfiguredReferenceItem(value) || value[0] === VARIABLE_NODE_ID) return; - - const sourceNode = getNodeById(value[0]); - const output = sourceNode?.outputs.find((item) => item.id === value[1]); - if (!sourceNode || !output) return; - - return { - reference: value, - sourceLabel: sourceNode.name, - outputLabel: output.label - }; +const getSelectedReferenceLabels = (value: unknown, list: ReferenceSelectList) => { + const reference = getReferenceListItem(value, list); + return reference ? [reference.source.label, reference.output.label] : []; }; +/** 按当前选项、节点输出、历史快照顺序恢复失效引用的展示标签。 */ const getReferenceSnapshot = ({ value, list, @@ -132,15 +93,63 @@ const getReferenceSnapshot = ({ referenceSnapshots?: WorkflowReferenceSnapshot[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; }) => { - if (!isConfiguredReferenceItem(value)) return; + if (!isWorkflowReferenceItem(value)) return; - return ( - getReferenceSnapshotFromList({ value, list }) ?? - getReferenceSnapshotFromSource({ value, getNodeById }) ?? - referenceSnapshots?.find((item) => isSameReference(item.reference, value)) - ); + const reference = getReferenceListItem(value, list); + if (reference) { + return { + reference: value, + sourceLabel: reference.source.sourceLabel, + outputLabel: reference.output.outputLabel + }; + } + + if (value[0] !== VARIABLE_NODE_ID) { + const sourceNode = getNodeById(value[0]); + const sourceOutput = sourceNode?.outputs.find((item) => item.id === value[1]); + if (sourceNode && sourceOutput) { + return { + reference: value, + sourceLabel: sourceNode.name, + outputLabel: sourceOutput.label + }; + } + } + + return referenceSnapshots?.find((item) => { + const reference = item.reference; + return ( + isWorkflowReferenceItem(reference) && reference[0] === value[0] && reference[1] === value[1] + ); + }); }; +const getInvalidReferenceReason = ( + code: ReturnType['code'], + t: ReturnType['t'] +) => + code === 'invalid_reference_type' + ? t('common:core.workflow.check.reference_type_mismatch') + : code === 'unreachable_reference' + ? t('common:core.workflow.check.reference_unreachable') + : t('common:core.workflow.check.reference_deleted'); + +const ReferenceOutputLabel = ({ + sourceLabel, + outputLabel, + iconMargin = 0.5 +}: { + sourceLabel: React.ReactNode; + outputLabel: React.ReactNode; + iconMargin?: number; +}) => ( + <> + {sourceLabel} + + {outputLabel} + +); + const formatReferenceSnapshots = ({ value, list, @@ -191,7 +200,6 @@ const ReferenceSnapshotLabel = ({ type CommonSelectProps = { placeholder?: string; list: ReferenceSelectList; - sourceNodeIds?: string[]; sourceNodes?: WorkflowReferenceSourceNode[]; valueType?: WorkflowIOValueTypeEnum; referenceSnapshots?: WorkflowReferenceSnapshot[]; @@ -226,7 +234,7 @@ export const useReference = ({ ); // 获取可选的变量列表 - const { referenceList, sourceNodeIds, sourceNodes } = useMemoEnhance(() => { + const { referenceList, sourceNodes } = useMemoEnhance(() => { const sourceNodes = getNodeAllSource({ nodeId, getNodeById, @@ -248,14 +256,6 @@ export const useReference = ({ {node.name} ); - const toChildren = (outputs: FlowNodeOutputItemType[]) => - outputs.map((output) => ({ - label: t(output.label as any), - value: output.id, - outputLabel: output.label, - valueType: output.valueType - })); - // 转换为 select 的数据结构 const selectableOutputs = filterSelectableWorkflowNodeOutputs({ outputs: node.outputs, @@ -267,14 +267,18 @@ export const useReference = ({ label, value: node.nodeId, sourceLabel: node.name, - children: toChildren(selectableOutputs) + children: selectableOutputs.map((output) => ({ + label: t(output.label as any), + value: output.id, + outputLabel: output.label, + valueType: output.valueType + })) }); } }); return { referenceList, - sourceNodeIds: sourceNodes.map((node) => node.nodeId), sourceNodes: sourceNodes.map((node) => ({ nodeId: node.nodeId, outputs: node.outputs, @@ -294,7 +298,6 @@ export const useReference = ({ return { referenceList, - sourceNodeIds, sourceNodes }; }; @@ -323,7 +326,7 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { [item, nodeId, onChangeNode] ); - const { referenceList, sourceNodeIds, sourceNodes } = useReference({ + const { referenceList, sourceNodes } = useReference({ nodeId, valueType: item.valueType }); @@ -338,7 +341,6 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { v.getNodeById); - const getSelectValue = useCallback((value: unknown, searchList: ReferenceSelectList) => { - if (!isConfiguredReferenceItem(value)) return []; - - const firstColumn = searchList.find((item) => item.value === value[0]); - if (!firstColumn) { - return []; - } - const secondColumn = firstColumn.children.find((item) => item.value === value[1]); - if (!secondColumn) { - return []; - } - return [firstColumn.label, secondColumn.label]; - }, []); - - const ItemSelector = useMemo(() => { - const selectorVal = value as ReferenceItemValueType; - const [nodeName, outputName] = getSelectValue(selectorVal, list); - const status = getWorkflowReferenceStatus({ - value: selectorVal, - valueType, - sourceNodeIds, - sourceNodes, - getNodeById - }); - const isValidSelect = status.code === 'valid' && Boolean(nodeName && outputName); - const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; - const referenceOutputKey = getReferenceOutputKey(selectorVal); - const referenceTitle = getReferenceValueTitle(selectorVal); - const invalidReason = - status.code === 'invalid_reference_type' - ? t('common:core.workflow.check.reference_type_mismatch') - : status.code === 'unreachable_reference' - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted'); - const invalidSnapshot = isInvalidReference - ? getReferenceSnapshot({ - value: selectorVal, + const selectorVal = value as ReferenceItemValueType; + const [nodeName, outputName] = getSelectedReferenceLabels(selectorVal, list); + const status = getWorkflowReferenceStatus({ + value: selectorVal, + valueType, + sourceNodes, + getNodeById + }); + const isValidSelect = status.code === 'valid' && Boolean(nodeName && outputName); + const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; + const referenceOutputKey = getReferenceOutputKey(selectorVal); + const referenceTitle = getReferenceValueTitle(selectorVal); + const invalidReason = getInvalidReferenceReason(status.code, t); + const invalidSnapshot = isInvalidReference + ? getReferenceSnapshot({ + value: selectorVal, + list, + referenceSnapshots, + getNodeById + }) + : undefined; + + const selector = ( + + + + ) : isInvalidReference ? ( + + + + + + ) : ( + + {placeholder} + + ) + } + value={selectorVal} + list={list} + onSelect={(nextValue) => { + const snapshots = formatReferenceSnapshots({ + value: nextValue as ReferenceValueType | undefined, list, referenceSnapshots, getNodeById - }) - : undefined; - - const selector = ( - - {nodeName} - - {outputName} - - ) : isInvalidReference ? ( - - - - - - ) : ( - - {placeholder} - - ) - } - value={selectorVal} - list={list} - onSelect={(nextValue) => { - const snapshots = formatReferenceSnapshots({ - value: nextValue as ReferenceValueType | undefined, - list, - referenceSnapshots, - getNodeById - }); - onSelect( - nextValue as ReferenceItemValueType | undefined, - snapshots.length ? snapshots : undefined - ); - }} - popDirection={popDirection} - ButtonProps={ - isInvalidReference - ? { - ...ButtonProps, - borderColor: 'red.500', - color: 'red.500', - _hover: { borderColor: 'red.400' } - } - : ButtonProps - } - /> - ); - - return isInvalidReference ? ( - - {selector} - - ) : ( - selector - ); - }, [ - ButtonProps, - getSelectValue, - getNodeById, - list, - onSelect, - placeholder, - popDirection, - referenceSnapshots, - sourceNodeIds, - sourceNodes, - t, - value, - valueType - ]); + }); + onSelect( + nextValue as ReferenceItemValueType | undefined, + snapshots.length ? snapshots : undefined + ); + }} + popDirection={popDirection} + ButtonProps={ + isInvalidReference + ? { + ...ButtonProps, + borderColor: 'red.500', + color: 'red.500', + _hover: { borderColor: 'red.400' } + } + : ButtonProps + } + /> + ); - return ItemSelector; + return isInvalidReference ? ( + + {selector} + + ) : ( + selector + ); }; const MultipleReferenceSelector = ({ placeholder, value, list = [], - sourceNodeIds, sourceNodes, valueType, referenceSnapshots, @@ -502,30 +462,15 @@ const MultipleReferenceSelector = ({ const { t } = useSafeTranslation(); const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); - const getSelectValue = useCallback((value: unknown, searchList: ReferenceSelectList) => { - if (!isConfiguredReferenceItem(value)) return []; - - const firstColumn = searchList.find((item) => item.value === value[0]); - if (!firstColumn) { - return []; - } - const secondColumn = firstColumn.children.find((item) => item.value === value[1]); - if (!secondColumn) { - return []; - } - return [firstColumn.label, secondColumn.label]; - }, []); - // Get valid item and remove invalid item const formatList = useMemo(() => { - if (!value || !Array.isArray(value)) return []; + if (!Array.isArray(value)) return []; return value.map((item) => { - const [nodeName, outputName] = getSelectValue(item, list); + const [nodeName, outputName] = getSelectedReferenceLabels(item, list); const status = getWorkflowReferenceStatus({ value: item, valueType, - sourceNodeIds, sourceNodes, getNodeById }); @@ -536,7 +481,7 @@ const MultipleReferenceSelector = ({ status }; }); - }, [getNodeById, getSelectValue, list, sourceNodeIds, sourceNodes, value, valueType]); + }, [getNodeById, list, sourceNodes, value, valueType]); const handleSelect = useCallback( (nextValue?: ReferenceArrayValueType) => { @@ -551,134 +496,109 @@ const MultipleReferenceSelector = ({ [getNodeById, list, onSelect, referenceSnapshots] ); - const ArraySelector = useMemo(() => { - return ( - 0 ? ( - - {formatList.map(({ rawValue, nodeName, outputName, status }, index) => { - const isValidReference = status.code === 'valid' && Boolean(nodeName && outputName); - const isInvalidReference = - isConfiguredReferenceValue(rawValue) && !isValidReference; - const referenceOutputKey = getReferenceOutputKey(rawValue); - const referenceTitle = getReferenceValueTitle(rawValue); - const invalidReason = - status.code === 'invalid_reference_type' - ? t('common:core.workflow.check.reference_type_mismatch') - : status.code === 'unreachable_reference' - ? t('common:core.workflow.check.reference_unreachable') - : t('common:core.workflow.check.reference_deleted'); - const invalidSnapshot = isInvalidReference - ? getReferenceSnapshot({ - value: rawValue, - list, - referenceSnapshots, - getNodeById - }) - : undefined; - const row = ( + return ( + 0 ? ( + + {formatList.map(({ rawValue, nodeName, outputName, status }, index) => { + const isValidReference = status.code === 'valid' && Boolean(nodeName && outputName); + const isInvalidReference = isConfiguredReferenceValue(rawValue) && !isValidReference; + const referenceOutputKey = getReferenceOutputKey(rawValue); + const referenceTitle = getReferenceValueTitle(rawValue); + const invalidReason = getInvalidReferenceReason(status.code, t); + const invalidSnapshot = isInvalidReference + ? getReferenceSnapshot({ + value: rawValue, + list, + referenceSnapshots, + getNodeById + }) + : undefined; + const row = ( + - - {isValidReference ? ( - <> - {nodeName} - - {outputName} - - ) : isInvalidReference ? ( - - ) : ( - {placeholder} - )} - - { - e.stopPropagation(); - handleSelect(value?.filter((_, i) => i !== index)); - }} - /> + {isValidReference ? ( + + ) : isInvalidReference ? ( + + ) : ( + {placeholder} + )} - ); - return isInvalidReference ? ( - - {row} - - ) : ( - row - ); - })} - - ) : ( - - {placeholder} - - ) - } - value={value as any} - list={list} - onSelect={(e) => { - handleSelect(e as ReferenceArrayValueType); - }} - popDirection={popDirection} - /> - ); - }, [ - formatList, - getNodeById, - list, - handleSelect, - placeholder, - popDirection, - referenceSnapshots, - t, - value - ]); - - return ArraySelector; + { + e.stopPropagation(); + handleSelect(value?.filter((_, i) => i !== index)); + }} + /> + + ); + return isInvalidReference ? ( + + {row} + + ) : ( + row + ); + })} + + ) : ( + + {placeholder} + + ) + } + value={value as any} + list={list} + onSelect={(e) => { + handleSelect(e as ReferenceArrayValueType); + }} + popDirection={popDirection} + /> + ); }; export const ReferSelector = (props: SelectProps) => { return props.isArray ? ( 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 f4cafb7607e0..3d5cd3f83cd1 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -189,8 +189,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => // 将 UI 流程数据转换为存储格式 const flowData2StoreData = useCallback(() => { const nodes = getNodes(); - return uiWorkflow2StoreWorkflow({ nodes, edges, chatConfig: appDetail.chatConfig }); - }, [getNodes, edges, appDetail.chatConfig]); + return uiWorkflow2StoreWorkflow({ nodes, edges }); + }, [getNodes, edges]); // 转换并验证工作流数据 const flowData2StoreDataAndCheck = useCallback( @@ -239,8 +239,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => onRemoveError(); const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, - edges, - chatConfig: appDetail.chatConfig + edges }); return storeWorkflow; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts index 6441c4a79e31..956d3b74cf8d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts @@ -1,6 +1,6 @@ import { filterSelectableWorkflowNodeOutputs, getNodeAllSource } from '@/web/core/workflow/utils'; import { workflowSystemVariables } from '@/web/core/app/utils'; -import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type'; +import { type AppDetailType } from '@fastgpt/global/core/app/type'; import { NodeInputKeyEnum, NodeOutputKeyEnum, @@ -40,7 +40,6 @@ export const uiWorkflow2StoreWorkflow = ({ }: { nodes: Node[]; edges: Edge[]; - chatConfig?: AppChatConfigType; }) => { const toolNodeIds = new Set( edges @@ -82,7 +81,7 @@ export const uiWorkflow2StoreWorkflow = ({ position: item.position, version: item.data.version, inputs, - outputs: item.data.outputs, + outputs: item.data.outputs.map(({ invalidCondition: _, ...output }) => output), isFolded: item.data.isFolded, pluginId: item.data.pluginId, toolConfig: item.data.toolConfig, diff --git a/projects/app/src/web/core/workflow/referenceCheck.ts b/projects/app/src/web/core/workflow/referenceCheck.ts index 3406d8a05719..3ec32733f20d 100644 --- a/projects/app/src/web/core/workflow/referenceCheck.ts +++ b/projects/app/src/web/core/workflow/referenceCheck.ts @@ -43,7 +43,7 @@ const isMalformedReferenceValue = (value: unknown) => { /** * 判断单项引用状态。输出可用性优先于来源范围,保证失效输出不会被误报为不可达。 - * 普通来源只在显式存在于 sourceNodeIds 时可用;global reference 单独按 chatConfig 查询。 + * 普通来源按 sourceNodeIds 或 sourceNodes 判断范围;global reference 单独按 chatConfig 查询。 */ export const getWorkflowReferenceStatus = ({ value, @@ -90,10 +90,13 @@ export const getWorkflowReferenceStatus = ({ return { code: 'invalid_reference', sourceType: sourceOutput.valueType }; } + const availableSourceNodeIds = sourceNodeIds + ? new Set(sourceNodeIds) + : sourceNodes && new Set(sourceNodes.map((node) => node.nodeId)); if ( sourceNodeId !== VARIABLE_NODE_ID && - sourceNodeIds && - !new Set(sourceNodeIds).has(sourceNodeId) + availableSourceNodeIds && + !availableSourceNodeIds.has(sourceNodeId) ) { return { code: 'unreachable_reference', sourceType: sourceOutput.valueType }; } @@ -151,7 +154,6 @@ export const workflowReferenceValueIsSelectable = ({ value: item, valueType, sourceNodes, - sourceNodeIds: sourceNodes.map((node) => node.nodeId), getNodeById: () => undefined, chatConfig }).code === 'valid' diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index ee4b3e8964f7..791520f39f52 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -35,7 +35,6 @@ import { import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; import { getNodeAllSourceIds, - getRefData, isConfiguredReferenceValue, isEmptyReferenceValue, isWorkflowEdgeSourceHandleValid, @@ -368,30 +367,6 @@ const createWorkflowCheckContext = ({ }; }; -const getReferenceStatuses = ({ - value, - valueType, - sourceNodeIds, - context -}: { - value: unknown; - valueType?: WorkflowIOValueTypeEnum; - sourceNodeIds: string[]; - context: WorkflowCheckContext; -}) => { - const getNodeById = (nodeId: string | null | undefined) => - context.nodeMap.get(nodeId ?? '')?.data; - return getWorkflowReferenceStatuses({ - value, - valueType, - sourceNodeIds, - getNodeById, - chatConfig: context.chatConfig - }); -}; - -const isEmptyReferenceInputValue = (value: unknown) => isEmptyReferenceValue(value); - const isVariableUpdateValueEmpty = (item: TUpdateListItem) => { if (item.renderType === FlowNodeInputTypeEnum.reference) { return !isConfiguredReferenceValue(item.value); @@ -471,6 +446,38 @@ export const checkWorkflowNodeIssues = ({ childrenNodeIdListMap: context.childrenNodeIdListMap }); }; + /** 统一引用状态、文案和字段路径,返回状态供后续类型校验复用。 */ + const addReferenceIssue = ({ + value, + valueType, + sourceNodeIds, + inputKey, + inputName + }: { + value: unknown; + valueType?: WorkflowIOValueTypeEnum; + sourceNodeIds: string[]; + inputKey: string; + inputName?: string; + }) => { + const statuses = getWorkflowReferenceStatuses({ + value, + valueType, + sourceNodeIds, + getNodeById: (nodeId) => context.nodeMap.get(nodeId ?? '')?.data, + chatConfig: context.chatConfig + }); + const code = getReferenceIssueCode(statuses); + if (code) { + addIssue({ + node, + code, + message: getWorkflowCheckIssueMessage(code, t, { inputName }), + inputKey + }); + } + return statuses; + }; const isToolNode = context.incomingEdgesMap .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); @@ -543,56 +550,26 @@ export const checkWorkflowNodeIssues = ({ if (ifElseList) { ifElseList.forEach((branch, branchIndex) => { branch.list.forEach((condition, conditionIndex) => { - const addReferenceIssue = ({ - field, - code - }: { - field: 'variable' | 'value'; - code: 'invalid_reference' | 'invalid_reference_type' | 'unreachable_reference'; - }) => { - addIssue({ - node, - code, - message: getWorkflowCheckIssueMessage(code, t, { - inputName: field === 'variable' ? '变量' : '值' - }), - inputKey: `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].${field}` - }); - }; - - const variableStatuses = getReferenceStatuses({ + const variableInputKey = `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].variable`; + const valueInputKey = `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].value`; + const variableStatuses = addReferenceIssue({ value: condition.variable, - sourceNodeIds, - context + inputKey: variableInputKey, + inputName: '变量', + sourceNodeIds }); - const variableIssueCode = getReferenceIssueCode(variableStatuses); - if (variableIssueCode) { - addReferenceIssue({ - field: 'variable', - code: variableIssueCode - }); - } - - const variableType = getRefData({ - variable: condition.variable, - getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, - chatConfig: context.chatConfig - }).valueType; + const variableType = variableStatuses.find( + ({ sourceType }) => sourceType + )?.sourceType; if (condition.valueType === 'reference') { - const valueStatuses = getReferenceStatuses({ + addReferenceIssue({ value: condition.value, + inputKey: valueInputKey, + inputName: '值', valueType: variableType, - sourceNodeIds, - context + sourceNodeIds }); - const valueIssueCode = getReferenceIssueCode(valueStatuses); - if (valueIssueCode) { - addReferenceIssue({ - field: 'value', - code: valueIssueCode - }); - } } }); }); @@ -786,13 +763,14 @@ export const checkWorkflowNodeIssues = ({ | 'invalid_reference_type' | 'unreachable_reference'; }) => { - const inputName = (() => { - if (field === 'variable') { - return t ? t('common:core.workflow.variable' as any) : '变量'; - } - - return t ? t('common:value' as any) : '值'; - })(); + const inputName = + field === 'variable' + ? t + ? t('common:core.workflow.variable' as any) + : '变量' + : t + ? t('common:value' as any) + : '值'; addIssue({ node, @@ -812,17 +790,15 @@ export const checkWorkflowNodeIssues = ({ addVariableUpdateIssue({ field: 'value', code: 'required_input_empty' }); } else { updateList.forEach((item, index) => { - const targetStatuses = getReferenceStatuses({ + const targetInputKey = `${NodeInputKeyEnum.updateList}[${index}].variable`; + const targetStatuses = addReferenceIssue({ value: item.variable, - sourceNodeIds, - context + inputKey: targetInputKey, + inputName: t ? t('common:core.workflow.variable' as any) : '变量', + sourceNodeIds }); const targetIssueCode = getReferenceIssueCode(targetStatuses); - const targetType = getRefData({ - variable: item.variable, - getNodeById: (targetNodeId) => context.nodeMap.get(targetNodeId ?? '')?.data, - chatConfig: context.chatConfig - }).valueType; + const targetType = targetStatuses.find(({ sourceType }) => sourceType)?.sourceType; if (!isConfiguredReferenceValue(item.variable)) { addVariableUpdateIssue({ @@ -830,16 +806,8 @@ export const checkWorkflowNodeIssues = ({ index, code: 'required_input_empty' }); - } else if (targetIssueCode) { - addVariableUpdateIssue({ - field: 'variable', - index, - code: targetIssueCode as - | 'invalid_reference' - | 'invalid_reference_type' - | 'unreachable_reference' - }); } else if ( + !targetIssueCode && item.valueType && !workflowValueTypeIsCompatible(item.valueType, targetType) ) { @@ -850,31 +818,21 @@ export const checkWorkflowNodeIssues = ({ }); } - const valueStatuses = - item.renderType === FlowNodeInputTypeEnum.reference - ? getReferenceStatuses({ - value: item.value, - valueType: targetType, - sourceNodeIds, - context - }) - : []; - const valueIssueCode = getReferenceIssueCode(valueStatuses); + if (item.renderType === FlowNodeInputTypeEnum.reference) { + addReferenceIssue({ + value: item.value, + inputKey: `${NodeInputKeyEnum.updateList}[${index}].value`, + inputName: t ? t('common:value' as any) : '值', + valueType: targetType, + sourceNodeIds + }); + } if (isVariableUpdateValueEmpty(item)) { addVariableUpdateIssue({ field: 'value', index, code: 'required_input_empty' }); - } else if (valueIssueCode) { - addVariableUpdateIssue({ - field: 'value', - index, - code: valueIssueCode as - | 'invalid_reference' - | 'invalid_reference_type' - | 'unreachable_reference' - }); } }); } @@ -897,24 +855,13 @@ export const checkWorkflowNodeIssues = ({ // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 const effectiveInputValue = input.value ?? input.defaultValue; - const referenceIssueCode = isReferenceInput - ? getReferenceIssueCode( - getReferenceStatuses({ - value: effectiveInputValue, - valueType: input.valueType, - sourceNodeIds: getInputSourceNodeIds(input), - context - }) - ) - : undefined; - if (referenceIssueCode) { - addIssue({ - node, - code: referenceIssueCode, - message: getWorkflowCheckIssueMessage(referenceIssueCode, t, { - inputName: getInputLabel(input, t) - }), - inputKey: input.key + if (isReferenceInput) { + addReferenceIssue({ + value: effectiveInputValue, + valueType: input.valueType, + sourceNodeIds: getInputSourceNodeIds(input), + inputKey: input.key, + inputName: getInputLabel(input, t) }); } @@ -938,7 +885,7 @@ export const checkWorkflowNodeIssues = ({ } const inputValueIsEmpty = isReferenceInput - ? isEmptyReferenceInputValue(effectiveInputValue) + ? isEmptyReferenceValue(effectiveInputValue) : isEmptyWorkflowInputValue(effectiveInputValue); if ( 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 27a68cd61067..187b1b717791 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -3289,6 +3289,29 @@ describe('workflow reference status', () => { ).toBe('invalid_reference'); }); + it('derives reachability from sourceNodes when sourceNodeIds is omitted', () => { + expect( + getWorkflowReferenceStatus({ + value: ['source', 'text'], + sourceNodes: [ + { + nodeId: 'source', + outputs: [ + { + id: 'text', + key: 'text', + label: 'text', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } + ], + getNodeById: () => undefined + }).code + ).toBe('valid'); + }); + it('reports malformed items in mixed multiple references', () => { const statuses = getWorkflowReferenceStatuses({ value: [['source', 'error'], 'malformed'], From 60c2117df6c26a11745c6e25fae2e21f6610a349 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 17:36:10 +0800 Subject: [PATCH 10/18] feat: invalid label style (temp) Signed-off-by: Nixieboluo --- .../VariableLabelPlugin/components/VariableLabel.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx index 3f99b4359531..30c3ea6b2f8c 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx @@ -14,6 +14,7 @@ export default function VariableLabel({ // avoid including '.' in the variable name. const [parentLabel, ...childLabels] = variableLabel.split('.'); const childLabel = childLabels.join('.'); + const isInvalid = parentLabel === 'undefined'; return ( <> @@ -23,11 +24,11 @@ export default function VariableLabel({ mx={'2px'} rounded={'4px'} px={1.5} - bg={parentLabel !== 'undefined' ? 'primary.50' : 'red.50'} - color={parentLabel !== 'undefined' ? 'myGray.900' : 'red.600'} - transform={parentLabel !== 'undefined' ? 'translateY(3px)' : ''} + bg={isInvalid ? 'red.50' : 'primary.50'} + color={isInvalid ? 'red.600' : 'myGray.900'} + transform={isInvalid ? '' : 'translateY(3px)'} > - {parentLabel !== 'undefined' ? ( + {!isInvalid ? ( {parentLabel} @@ -35,7 +36,7 @@ export default function VariableLabel({ {childLabel} ) : ( - {t('common:invalid_variable')} + {t('common:invalid_variable')} )} From 8e81ddf6c630d88645d0014917dca1d6e04916f9 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 17:36:55 +0800 Subject: [PATCH 11/18] refactor(workflow): no reference snapshot migrations Signed-off-by: Nixieboluo --- .../core/workflow/migration/legacy/schema.ts | 5 +-- .../workflow/migration/legacy/workflow.ts | 39 ------------------ .../core/workflow/migration/schema.test.ts | 40 ++----------------- 3 files changed, 5 insertions(+), 79 deletions(-) diff --git a/packages/global/core/workflow/migration/legacy/schema.ts b/packages/global/core/workflow/migration/legacy/schema.ts index 03fb7795df4c..9a88280321dc 100644 --- a/packages/global/core/workflow/migration/legacy/schema.ts +++ b/packages/global/core/workflow/migration/legacy/schema.ts @@ -14,12 +14,9 @@ const LegacyAppChatConfigSchema = AppChatConfigTypeSchema.omit({ questionGuide: /** 历史工作流输入。旧索引只允许在外部数据迁移阶段出现。 */ export const LegacyFlowNodeInputItemSchema = FlowNodeInputItemTypeSchema.omit({ - renderTypeList: true, - referenceSnapshots: true + renderTypeList: true }).extend({ renderTypeList: z.array(z.enum(FlowNodeInputTypeEnum)).optional(), - // snapshot/reference shape is cleaned after legacy structure migration. - referenceSnapshots: z.unknown().optional(), isToolParam: BoolSchema.optional().meta({ description: '历史工具输入默认由 Agent 生成标记', deprecated: true diff --git a/packages/global/core/workflow/migration/legacy/workflow.ts b/packages/global/core/workflow/migration/legacy/workflow.ts index ef2725513c85..58b8a58f873c 100644 --- a/packages/global/core/workflow/migration/legacy/workflow.ts +++ b/packages/global/core/workflow/migration/legacy/workflow.ts @@ -16,7 +16,6 @@ import { isValidReferenceValueFormat, nodeInputIsReference } from '../../utils'; -import type { WorkflowReferenceSnapshot } from '../../type/io'; import type { NodeToolConfigType } from '../../type/node'; const isReferenceItem = (value: unknown): value is [string, string] => @@ -52,20 +51,6 @@ const cleanReferenceValue = (value: unknown, isArray: boolean) => { return isEmptyReferenceValue(value) ? value : undefined; }; -const cleanReferenceSnapshots = (value: unknown) => { - if (!Array.isArray(value)) return undefined; - - return value.filter((item): item is WorkflowReferenceSnapshot => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return false; - const snapshot = item as Record; - return ( - isReferenceItem(snapshot.reference) && - (snapshot.sourceLabel === undefined || typeof snapshot.sourceLabel === 'string') && - (snapshot.outputLabel === undefined || typeof snapshot.outputLabel === 'string') - ); - }); -}; - const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { const cleanInput = (input: CanonicalWorkflowData['nodes'][number]['inputs'][number]) => { const nextInput = { ...input }; @@ -75,12 +60,6 @@ const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { else nextInput.value = value; } - if (input.referenceSnapshots !== undefined) { - const snapshots = cleanReferenceSnapshots(input.referenceSnapshots); - if (snapshots?.length) nextInput.referenceSnapshots = snapshots; - else delete nextInput.referenceSnapshots; - } - if ( node.flowNodeType === FlowNodeTypeEnum.ifElseNode && input.key === NodeInputKeyEnum.ifElseList @@ -103,16 +82,6 @@ const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { if (value === undefined) delete nextCondition.value; else nextCondition.value = value; } - const variableSnapshot = cleanReferenceSnapshots( - nextCondition.variableSnapshot ? [nextCondition.variableSnapshot] : undefined - )?.[0]; - if (variableSnapshot) nextCondition.variableSnapshot = variableSnapshot; - else delete nextCondition.variableSnapshot; - const valueSnapshot = cleanReferenceSnapshots( - nextCondition.valueSnapshot ? [nextCondition.valueSnapshot] : undefined - )?.[0]; - if (valueSnapshot) nextCondition.valueSnapshot = valueSnapshot; - else delete nextCondition.valueSnapshot; return nextCondition; }) : nextBranch.list; @@ -140,14 +109,6 @@ const cleanReferenceFields = (node: CanonicalWorkflowData['nodes'][number]) => { if (value === undefined) delete nextItem.value; else nextItem.value = value; } - const variableSnapshot = cleanReferenceSnapshots( - nextItem.variableSnapshot ? [nextItem.variableSnapshot] : undefined - )?.[0]; - if (variableSnapshot) nextItem.variableSnapshot = variableSnapshot; - else delete nextItem.variableSnapshot; - const valueSnapshots = cleanReferenceSnapshots(nextItem.valueReferenceSnapshots); - if (valueSnapshots?.length) nextItem.valueReferenceSnapshots = valueSnapshots; - else delete nextItem.valueReferenceSnapshots; return nextItem; }) : input.value; diff --git a/packages/global/test/core/workflow/migration/schema.test.ts b/packages/global/test/core/workflow/migration/schema.test.ts index 1ba45aadf916..afb18fadc645 100644 --- a/packages/global/test/core/workflow/migration/schema.test.ts +++ b/packages/global/test/core/workflow/migration/schema.test.ts @@ -83,16 +83,7 @@ describe('workflow input schema boundaries', () => { label: 'Single', renderTypeList: [FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.string, - value: ['deleted-node', 'deleted-output'], - referenceSnapshots: [ - { - reference: ['deleted-node', 'deleted-output'], - sourceLabel: 'Deleted node', - outputLabel: 'Deleted output' - }, - { reference: ['malformed'], sourceLabel: 1 }, - { reference: ['deleted-node', undefined] } - ] + value: ['deleted-node', 'deleted-output'] }, { key: 'malformed-single', @@ -130,14 +121,9 @@ describe('workflow input schema boundaries', () => { list: [ { variable: ['deleted-node', 'variable'], - variableSnapshot: { - reference: ['deleted-node', 'variable'], - sourceLabel: 'Deleted node' - }, condition: 'isEqual', valueType: 'reference', - value: ['deleted-node', 'value'], - valueSnapshot: { reference: ['deleted-node', undefined] } + value: ['deleted-node', 'value'] } ] } @@ -163,10 +149,6 @@ describe('workflow input schema boundaries', () => { value: [ ['deleted-node', 'value'], ['deleted-node', undefined] - ], - valueReferenceSnapshots: [ - { reference: ['deleted-node', 'value'], sourceLabel: 'Deleted node' }, - { reference: ['deleted-node', undefined] } ] } ] @@ -185,32 +167,18 @@ describe('workflow input schema boundaries', () => { expect(consumer.isFolded).toBe(true); expect(consumer.inputs[0]).toMatchObject({ - value: ['deleted-node', 'deleted-output'], - referenceSnapshots: [ - { - reference: ['deleted-node', 'deleted-output'], - sourceLabel: 'Deleted node', - outputLabel: 'Deleted output' - } - ] + value: ['deleted-node', 'deleted-output'] }); expect(consumer.inputs[1]).not.toHaveProperty('value'); expect(consumer.inputs[2].value).toEqual([['kept-node', 'kept-output']]); const condition = (ifElse.inputs[0].value as any)[0].list[0]; expect(condition.variable).toEqual(['deleted-node', 'variable']); - expect(condition.variableSnapshot).toMatchObject({ - reference: ['deleted-node', 'variable'] - }); expect(condition.value).toEqual(['deleted-node', 'value']); - expect(condition).not.toHaveProperty('valueSnapshot'); const update = (variableUpdate.inputs[0].value as any)[0]; expect(update.variable).toEqual(['deleted-node', 'target']); expect(update.value).toEqual(['deleted-node', 'value']); - expect(update.valueReferenceSnapshots).toEqual([ - { reference: ['deleted-node', 'value'], sourceLabel: 'Deleted node' } - ]); expect(await migrateWorkflowToCurrent(result as any)).toEqual(result); }); @@ -909,7 +877,7 @@ describe('workflow migration boundary', () => { }); }); - it('rejects legacy tool input snapshots and invalid modes', async () => { + it('rejects invalid legacy tool input modes', async () => { expect(() => migrateWorkflowToCurrent({ nodes: [ From 20f19b65b21a4cd5c392c3a3bda33cd9dacc9e7d Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 1 Sep 2026 18:18:12 +0800 Subject: [PATCH 12/18] refactor(workflow): capture deleted reference labels --- .../common/MySelect/MultipleRowSelect.tsx | 24 +- .../Flow/hooks/useDebug.tsx | 3 +- .../Flow/nodes/NodeDatasetConcat.tsx | 8 +- .../Flow/nodes/NodeIfElse/ListItem.tsx | 18 +- .../Flow/nodes/NodePluginIO/PluginOutput.tsx | 8 +- .../NodeVariableUpdate/ValueRenderer.tsx | 5 +- .../NodeVariableUpdate/VariableSelector.tsx | 2 +- .../Flow/nodes/NodeVariableUpdate/index.tsx | 7 +- .../templates/DynamicInputs/index.tsx | 6 +- .../RenderInput/templates/Reference.tsx | 89 ++---- .../context/workflowActionsContext.tsx | 69 +++-- .../context/workflowInitContext.tsx | 27 +- .../context/workflowUtilsContext.tsx | 21 ++ .../app/detail/WorkflowComponents/utils.ts | 5 +- .../src/web/core/workflow/referenceCheck.ts | 258 +++++++++++++++++- projects/app/src/web/core/workflow/utils.ts | 1 + .../src/web/core/workflow/workflowCheck.ts | 243 +++++++++++------ .../detail/WorkflowComponents/utils.test.ts | 231 +++++++++++++++- .../test/web/core/app/workflow/utils.test.ts | 29 +- 19 files changed, 785 insertions(+), 269 deletions(-) diff --git a/packages/web/components/common/MySelect/MultipleRowSelect.tsx b/packages/web/components/common/MySelect/MultipleRowSelect.tsx index 2024b1fee549..a2b3b2ae2df6 100644 --- a/packages/web/components/common/MySelect/MultipleRowSelect.tsx +++ b/packages/web/components/common/MySelect/MultipleRowSelect.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/refs */ -import React, { useRef, useCallback, useState, useEffect } from 'react'; +import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'; import { Button, useDisclosure, @@ -306,10 +306,22 @@ export const MultipleRowArraySelect = ({ const [navigationPath, setNavigationPath] = useState([]); + // Make sure the value is an array of arrays + const formatValue = useMemo(() => { + return Array.isArray(value) ? value.filter((v) => Array.isArray(v)) : []; + }, [value]); + useOutsideClick({ ref: ref, handler: onClose }); + const onChange = useCallback( + (val: any[][]) => { + // 保留当前列表中已失效的引用,交给上层展示并在用户删除时移除。 + onSelect(val); + }, + [onSelect] + ); const RenderList = useCallback( ({ index, list }: { index: number; list: MultipleSelectProps['list'] }) => { @@ -326,13 +338,13 @@ export const MultipleRowArraySelect = ({ setNavigationPath(newPath); } else { const parentValue = navigationPath[0]; - const newValues = [...value]; + const newValues = [...formatValue]; const newValue = [parentValue, item.value]; if (newValues.some((v) => v[0] === parentValue && v[1] === item.value)) { - onSelect(newValues.filter((v) => !(v[0] === parentValue && v[1] === item.value))); + onChange(newValues.filter((v) => !(v[0] === parentValue && v[1] === item.value))); } else { - onSelect([...newValues, newValue]); + onChange([...newValues, newValue]); } } }; @@ -353,7 +365,7 @@ export const MultipleRowArraySelect = ({ const showCheckbox = !hasChildren; const isChecked = showCheckbox && - value.some((v) => v[1] === item.value && v[0] === navigationPath[0]); + formatValue.some((v) => v[1] === item.value && v[0] === navigationPath[0]); return ( ); }, - [navigationPath, maxH, emptyTip, t, value, onSelect] + [navigationPath, maxH, emptyTip, t, formatValue, onChange] ); const onOpenSelect = useCallback(() => { 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 235ec89c95ef..0d342fd6de49 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 @@ -116,7 +116,8 @@ export const useDebug = () => { onRemoveError(); const storeNodes = uiWorkflow2StoreWorkflow({ nodes, - edges + edges, + chatConfig: appDetail.chatConfig }); return JSON.stringify(storeNodes); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx index 3faa83da53c3..04a648d9cfdf 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeDatasetConcat.tsx @@ -11,8 +11,7 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { getOneQuoteInputTemplate } from '@fastgpt/global/core/workflow/template/system/datasetConcat'; import { type FlowNodeInputItemType, - type ReferenceItemValueType, - type WorkflowReferenceSnapshot + type ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io'; import RenderOutput from './render/RenderOutput'; import IOTitle from '../components/IOTitle'; @@ -160,15 +159,14 @@ const VariableSelector = ({ }); const onSelect = useCallback( - (e?: ReferenceItemValueType, snapshots?: WorkflowReferenceSnapshot[]) => { + (e?: ReferenceItemValueType) => { onChangeNode({ nodeId, type: 'replaceInput', key: inputChildren.key, value: { ...inputChildren, - value: e, - referenceSnapshots: e && snapshots?.length ? snapshots : undefined + value: e } }); }, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx index 9eb01b9bfc55..301b88d881de 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx @@ -137,7 +137,7 @@ const ListItem = ({ nodeId={nodeId} variable={item.variable} variableSnapshot={item.variableSnapshot} - onSelect={(e, snapshots) => { + onSelect={(e) => { onUpdateIfElseList( ifElseList.map((ifElse, index) => { if (index === conditionIndex) { @@ -148,7 +148,6 @@ const ListItem = ({ return { ...item, variable: e, - variableSnapshot: snapshots?.[0], condition: undefined }; } @@ -195,7 +194,7 @@ const ListItem = ({ variable={item.variable} valueSnapshot={item.valueSnapshot} nodeId={nodeId} - updateValue={(value, valueType, snapshot) => { + updateValue={(value, valueType) => { onUpdateIfElseList( ifElseList.map((ifElse, index) => { return { @@ -207,7 +206,6 @@ const ListItem = ({ return { ...item, value, - valueSnapshot: snapshot, valueType }; } @@ -326,7 +324,7 @@ const VariableSelector = ({ nodeId: string; variable?: ReferenceItemValueType; variableSnapshot?: WorkflowReferenceSnapshot; - onSelect: (e?: ReferenceItemValueType, snapshots?: WorkflowReferenceSnapshot[]) => void; + onSelect: (e?: ReferenceItemValueType) => void; }) => { const { t } = useTranslation(); @@ -443,11 +441,7 @@ const ConditionValueInput = ({ valueType?: 'input' | 'reference'; variable?: ReferenceItemValueType; condition?: VariableConditionEnum; - updateValue: ( - value: string | ReferenceItemValueType, - valueType: 'input' | 'reference', - snapshot?: WorkflowReferenceSnapshot - ) => void; + updateValue: (value: string | ReferenceItemValueType, valueType: 'input' | 'reference') => void; nodeId: string; valueSnapshot?: WorkflowReferenceSnapshot; }) => { @@ -585,8 +579,8 @@ const ConditionValueInput = ({ valueType={referenceValueType} referenceSnapshots={valueSnapshot ? [valueSnapshot] : undefined} value={isReference ? (value as ReferenceItemValueType) : undefined} - onSelect={(e, snapshots) => { - updateValue(e as ReferenceItemValueType, 'reference', snapshots?.[0]); + onSelect={(e) => { + updateValue(e as ReferenceItemValueType, 'reference'); }} isArray={false} ButtonProps={{ diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx index e33b353bcca0..cbe56e46a14b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/PluginOutput.tsx @@ -7,8 +7,7 @@ import { SmallAddIcon } from '@chakra-ui/icons'; import Container from '../../components/Container'; import { type FlowNodeInputItemType, - type ReferenceValueType, - type WorkflowReferenceSnapshot + type ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { useTranslation } from 'next-i18next'; @@ -110,15 +109,14 @@ function Reference({ const [editField, setEditField] = useState(); const onSelect = useCallback( - (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { + (e?: ReferenceValueType) => { onChangeNode({ nodeId, type: 'updateInput', key: input.key, value: { ...input, - value: e, - referenceSnapshots: e && snapshots?.length ? snapshots : undefined + value: e } }); }, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx index ac92a284dffc..f0ab4be4cbca 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx @@ -65,10 +65,9 @@ const ValueRenderer: React.FC = (props) => { variable={value} valueType={valueType} referenceSnapshots={valueReferenceSnapshots} - onSelect={(v, snapshots) => + onSelect={(v) => onChange({ - value: v, - valueReferenceSnapshots: snapshots?.length ? snapshots : undefined + value: v }) } /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx index b17a98c4e8b6..d2ad9334cde7 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/VariableSelector.tsx @@ -12,7 +12,7 @@ type Props = { variable?: ReferenceValueType; valueType?: WorkflowIOValueTypeEnum; referenceSnapshots?: WorkflowReferenceSnapshot[]; - onSelect: (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => void; + onSelect: (e?: ReferenceValueType) => void; }; const VariableSelector = ({ nodeId, variable, valueType, referenceSnapshots, onSelect }: Props) => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx index f683090d1a2c..ec805d9d9242 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx @@ -198,7 +198,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => referenceSnapshots={ updateItem.variableSnapshot ? [updateItem.variableSnapshot] : undefined } - onSelect={(value, snapshots) => { + onSelect={(value) => { const newValueType = getRefData({ variable: value as ReferenceItemValueType, getNodeById, @@ -206,8 +206,6 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => }).valueType; applyPatch({ variable: value as ReferenceItemValueType, - variableSnapshot: snapshots?.[0], - valueReferenceSnapshots: undefined, ...getDefaultsForValueType(newValueType) }); }} @@ -237,8 +235,7 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => : FlowNodeInputTypeEnum.input; applyPatch({ renderType: nt, - value: undefined, - valueReferenceSnapshots: undefined + value: undefined }); }} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx index a7c302d2fadd..548ebe400028 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx @@ -5,8 +5,7 @@ import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { type FlowNodeInputItemType, - type ReferenceValueType, - type WorkflowReferenceSnapshot + type ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; import { useContextSelector } from 'use-context-selector'; import { getInputComponentProps } from '@/web/core/workflow/utils'; @@ -188,7 +187,7 @@ const Reference = ({ [existsKeys, toast, t, isEmptyItem, item, onChangeNode, nodeId, inputChildren] ); const onSelectReference = useCallback( - (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { + (e?: ReferenceValueType) => { const referenceItem = e ? referenceList .find((item) => item.value === e[0]) @@ -202,7 +201,6 @@ const Reference = ({ value: { ...inputChildren, value: e, - referenceSnapshots: e && snapshots?.length ? snapshots : undefined, valueType: referenceItem?.valueType || WorkflowIOValueTypeEnum.any } }); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 8f8d5998f9c8..084c650cf580 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -4,16 +4,18 @@ import { Flex, Box, type ButtonProps, Grid } from '@chakra-ui/react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { getNodeAllSource, - getWorkflowReferenceItems, isConfiguredReferenceValue, isWorkflowReferenceItem, filterSelectableWorkflowNodeOutputs, type WorkflowReferenceSourceNode } from '@/web/core/workflow/utils'; -import { getWorkflowReferenceStatus } from '@/web/core/workflow/referenceCheck'; +import { + getWorkflowReferenceSource, + getWorkflowReferenceStatus +} from '@/web/core/workflow/referenceCheck'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; -import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import type { ReferenceArrayValueType, ReferenceItemValueType, @@ -81,15 +83,17 @@ const getSelectedReferenceLabels = (value: unknown, list: ReferenceSelectList) = return reference ? [reference.source.label, reference.output.label] : []; }; -/** 按当前选项、节点输出、历史快照顺序恢复失效引用的展示标签。 */ +/** 按当前选项或历史快照恢复失效引用的展示标签。 */ const getReferenceSnapshot = ({ value, list, + sourceNodes, referenceSnapshots, getNodeById }: { value?: ReferenceItemValueType; list: ReferenceSelectList; + sourceNodes?: WorkflowReferenceSourceNode[]; referenceSnapshots?: WorkflowReferenceSnapshot[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; }) => { @@ -104,16 +108,17 @@ const getReferenceSnapshot = ({ }; } - if (value[0] !== VARIABLE_NODE_ID) { - const sourceNode = getNodeById(value[0]); - const sourceOutput = sourceNode?.outputs.find((item) => item.id === value[1]); - if (sourceNode && sourceOutput) { - return { - reference: value, - sourceLabel: sourceNode.name, - outputLabel: sourceOutput.label - }; - } + const { sourceLabel, sourceOutput } = getWorkflowReferenceSource({ + value, + sourceNodes, + getNodeById + }); + if (sourceLabel && sourceOutput) { + return { + reference: value, + sourceLabel, + outputLabel: sourceOutput.label + }; } return referenceSnapshots?.find((item) => { @@ -150,28 +155,6 @@ const ReferenceOutputLabel = ({ ); -const formatReferenceSnapshots = ({ - value, - list, - referenceSnapshots, - getNodeById -}: { - value?: ReferenceValueType; - list: ReferenceSelectList; - referenceSnapshots?: WorkflowReferenceSnapshot[]; - getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; -}) => - getWorkflowReferenceItems(value) - .map((item) => - getReferenceSnapshot({ - value: item, - list, - referenceSnapshots, - getNodeById - }) - ) - .filter((item): item is WorkflowReferenceSnapshot => !!item); - const ReferenceSnapshotLabel = ({ snapshot, fallback, @@ -209,10 +192,7 @@ type CommonSelectProps = { type SelectProps = CommonSelectProps & { isArray?: T; value?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType; - onSelect: ( - val?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType, - snapshots?: WorkflowReferenceSnapshot[] - ) => void; + onSelect: (val?: T extends true ? ReferenceArrayValueType : ReferenceItemValueType) => void; }; export const useReference = ({ @@ -281,6 +261,7 @@ export const useReference = ({ referenceList, sourceNodes: sourceNodes.map((node) => ({ nodeId: node.nodeId, + sourceLabel: node.name, outputs: node.outputs, catchError: node.catchError })) @@ -311,15 +292,14 @@ const Reference = ({ item, nodeId }: RenderInputProps) => { const isArray = item.valueType?.includes('array') ?? false; const onSelect = useCallback( - (e?: ReferenceValueType, snapshots?: WorkflowReferenceSnapshot[]) => { + (e?: ReferenceValueType) => { onChangeNode({ nodeId, type: 'updateInput', key: item.key, value: { ...item, - value: e, - referenceSnapshots: snapshots?.length ? snapshots : undefined + value: e } }); }, @@ -385,6 +365,7 @@ const SingleReferenceSelector = ({ ? getReferenceSnapshot({ value: selectorVal, list, + sourceNodes, referenceSnapshots, getNodeById }) @@ -416,16 +397,7 @@ const SingleReferenceSelector = ({ value={selectorVal} list={list} onSelect={(nextValue) => { - const snapshots = formatReferenceSnapshots({ - value: nextValue as ReferenceValueType | undefined, - list, - referenceSnapshots, - getNodeById - }); - onSelect( - nextValue as ReferenceItemValueType | undefined, - snapshots.length ? snapshots : undefined - ); + onSelect(nextValue as ReferenceItemValueType | undefined); }} popDirection={popDirection} ButtonProps={ @@ -485,15 +457,9 @@ const MultipleReferenceSelector = ({ const handleSelect = useCallback( (nextValue?: ReferenceArrayValueType) => { - const snapshots = formatReferenceSnapshots({ - value: nextValue, - list, - referenceSnapshots, - getNodeById - }); - onSelect(nextValue, snapshots.length ? snapshots : undefined); + onSelect(nextValue); }, - [getNodeById, list, onSelect, referenceSnapshots] + [onSelect] ); return ( @@ -521,6 +487,7 @@ const MultipleReferenceSelector = ({ ? getReferenceSnapshot({ value: rawValue, list, + sourceNodes, referenceSnapshots, getNodeById }) 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 37d4dd998ce7..f74e676c3560 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -22,6 +22,7 @@ import { countNewWorkflowCheckIssues } from '@/web/core/workflow/workflowCheck'; import { collectWorkflowStartAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill'; +import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import { AppContext } from '@/pageComponents/app/detail/context'; @@ -415,37 +416,38 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod // 确保重置时不阻塞快照保存 forbiddenSaveSnapshotRef.current = false; - setNodes((state) => - state.map((item) => { - if (item.id === id) { - const sourceInputMap = new Map(item.data.inputs.map((input) => [input.key, input])); - return { - ...item, - data: { - ...item.data, - ...node, - inputs: node.inputs.map((input) => { - const previousInput = sourceInputMap.get(input.key); - return { - ...migrateToolInputConfig({ - input, - sourceInput: previousInput - }), - ...(previousInput?.referenceSnapshots === undefined - ? {} - : { referenceSnapshots: previousInput.referenceSnapshots }) - }; + setNodes((state) => { + const nextNodes = state.map((item) => { + if (item.id !== id) return item; + + const sourceInputMap = new Map(item.data.inputs.map((input) => [input.key, input])); + return { + ...item, + data: { + ...item.data, + ...node, + inputs: node.inputs.map((input) => + migrateToolInputConfig({ + input, + sourceInput: sourceInputMap.get(input.key) }) - } - }; - } - return item; - }) - ); + ) + } + }; + }); + + return captureDeletedWorkflowReferenceSnapshots({ + previousNodes: state, + nextNodes, + previousChatConfig: appDetail.chatConfig, + nextChatConfig: appDetail.chatConfig, + globalVariableSourceLabel: t('common:core.module.Variable') + }); + }); scheduleFullWorkflowCheck(); }, - [forbiddenSaveSnapshotRef, scheduleFullWorkflowCheck, setNodes] + [appDetail.chatConfig, forbiddenSaveSnapshotRef, scheduleFullWorkflowCheck, setNodes, t] ); // 使用结构共享优化的节点更改 @@ -469,7 +471,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }, new Map()); setNodes((nodes) => { - return nodes.map((node) => { + const nextNodes = nodes.map((node) => { const updateItems = updatesByNodeId.get(node.data.nodeId); if (!updateItems?.length) return node; @@ -598,6 +600,14 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod data: updateObj }; }); + + return captureDeletedWorkflowReferenceSnapshots({ + previousNodes: nodes, + nextNodes, + previousChatConfig: appDetail.chatConfig, + nextChatConfig: appDetail.chatConfig, + globalVariableSourceLabel: t('common:core.module.Variable') + }); }); // 变更影响「其他节点」上的引用有效性,单节点复查覆盖不到,需要升级为全量检查: @@ -643,7 +653,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod onDelEdge, llmModelMap, scheduleSingleNodeWorkflowCheck, - scheduleFullWorkflowCheck + scheduleFullWorkflowCheck, + appDetail.chatConfig ] ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx index d43c3b3113c1..5b46cf469393 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx @@ -1,5 +1,8 @@ -import { createContext } from 'use-context-selector'; -import type { FlowNodeTemplateType, FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import { createContext, useContextSelector } from 'use-context-selector'; +import type { + FlowNodeTemplateType, + FlowNodeItemType +} from '@fastgpt/global/core/workflow/type/node'; import { useDeepCompareEffect, useMemoizedFn } from 'ahooks'; import React, { @@ -15,6 +18,7 @@ import { type EdgeChange, type Node, type NodeChange, + applyNodeChanges, useEdgesState, useNodesState } from 'reactflow'; @@ -22,6 +26,8 @@ import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { getWebLLMModel } from '@/web/common/system/utils'; +import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; +import { AppContext } from '@/pageComponents/app/detail/context'; type OnChange = (changes: ChangesType[]) => void; @@ -121,7 +127,22 @@ const WorkflowInitContextProvider = ({ basicNodeTemplates: FlowNodeTemplateType[]; }) => { // Nodes - const [nodes = [], setNodes, onNodesChange] = useNodesState([]); + const [nodes = [], setNodes] = useNodesState([]); + const chatConfig = useContextSelector(AppContext, (v) => v.appDetail.chatConfig); + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + setNodes((state) => { + const nextNodes = applyNodeChanges(changes, state); + return captureDeletedWorkflowReferenceSnapshots({ + previousNodes: state, + nextNodes, + previousChatConfig: chatConfig, + nextChatConfig: chatConfig + }); + }); + }, + [chatConfig, setNodes] + ); const getNodes = useMemoizedFn(() => nodes); const nodeFormat = useMemo(() => { 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 3d5cd3f83cd1..2837b894c41b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -4,6 +4,7 @@ import { createContext, useContextSelector } from 'use-context-selector'; import { useReactFlow } from 'reactflow'; import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; +import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import { storeNode2FlowNode, storeEdge2RenderEdge } from '@/web/core/workflow/utils'; import { checkWorkflowBeforeRunOrPublish, @@ -33,6 +34,7 @@ import { canInputBeAgentGenerated, normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; +import { isEqual } from 'lodash-es'; // 创建 Context type WorkflowUtilsContextValue = { @@ -135,6 +137,7 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => const enableSandbox = !teamPlanStatus?.standard || !!teamPlanStatus?.standard?.enableSandbox; const { appDetail, setAppDetail } = useContextSelector(AppContext, (v) => v); + const previousChatConfigRef = useRef(appDetail.chatConfig); const { edges, setEdges, setNodes, getNodes, toolNodesMap } = useContextSelector( WorkflowBufferDataContext, (v) => v @@ -145,6 +148,24 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => (v) => v ); + useEffect(() => { + const previousChatConfig = previousChatConfigRef.current; + const nextChatConfig = appDetail.chatConfig; + previousChatConfigRef.current = nextChatConfig; + + if (isEqual(previousChatConfig?.variables, nextChatConfig?.variables)) return; + + setNodes((nodes) => + captureDeletedWorkflowReferenceSnapshots({ + previousNodes: nodes, + nextNodes: nodes, + previousChatConfig, + nextChatConfig, + globalVariableSourceLabel: t('common:core.module.Variable') + }) + ); + }, [appDetail.chatConfig, setNodes, t]); + // 优化为单次遍历,分类输出项 const splitOutput = useCallback((outputs: FlowNodeOutputItemType[]) => { const successOutputs: FlowNodeOutputItemType[] = []; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts index 956d3b74cf8d..6441c4a79e31 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts @@ -1,6 +1,6 @@ import { filterSelectableWorkflowNodeOutputs, getNodeAllSource } from '@/web/core/workflow/utils'; import { workflowSystemVariables } from '@/web/core/app/utils'; -import { type AppDetailType } from '@fastgpt/global/core/app/type'; +import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type'; import { NodeInputKeyEnum, NodeOutputKeyEnum, @@ -40,6 +40,7 @@ export const uiWorkflow2StoreWorkflow = ({ }: { nodes: Node[]; edges: Edge[]; + chatConfig?: AppChatConfigType; }) => { const toolNodeIds = new Set( edges @@ -81,7 +82,7 @@ export const uiWorkflow2StoreWorkflow = ({ position: item.position, version: item.data.version, inputs, - outputs: item.data.outputs.map(({ invalidCondition: _, ...output }) => output), + outputs: item.data.outputs, isFolded: item.data.isFolded, pluginId: item.data.pluginId, toolConfig: item.data.toolConfig, diff --git a/projects/app/src/web/core/workflow/referenceCheck.ts b/projects/app/src/web/core/workflow/referenceCheck.ts index 3ec32733f20d..0fd0f5de8d53 100644 --- a/projects/app/src/web/core/workflow/referenceCheck.ts +++ b/projects/app/src/web/core/workflow/referenceCheck.ts @@ -1,7 +1,25 @@ -import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import type { ReferenceValueType } from '@fastgpt/global/core/workflow/type/io'; +import { + NodeInputKeyEnum, + VARIABLE_NODE_ID, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { + FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import type { + FlowNodeInputItemType, + ReferenceItemValueType, + ReferenceValueType, + WorkflowReferenceSnapshot +} from '@fastgpt/global/core/workflow/type/io'; import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { AppChatConfigType } from '@fastgpt/global/core/app/type'; +import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type'; +import type { TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; +import type { Node } from 'reactflow'; +import { isEqual } from 'lodash-es'; +import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; import { filterSelectableWorkflowNodeOutputs, getWorkflowGlobalVariables, @@ -29,7 +47,6 @@ export type WorkflowReferenceIssueCode = Exclude; sourceNodes?: WorkflowReferenceSourceNode[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; chatConfig?: AppChatConfigType; @@ -41,14 +58,41 @@ const isMalformedReferenceValue = (value: unknown) => { return value.some((item) => !isWorkflowReferenceItem(item)); }; +/** 按引用 ID 查找来源节点和输出;sourceNodes 优先,未命中时回退到当前节点表。 */ +export const getWorkflowReferenceSource = ({ + value, + sourceNodes, + getNodeById +}: { + value: unknown; + sourceNodes?: WorkflowReferenceSourceNode[]; + getNodeById?: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; +}) => { + if (!isWorkflowReferenceItem(value)) return {}; + + const [sourceNodeId, outputId] = value; + const sourceNode = + sourceNodes?.find((node) => node.nodeId === sourceNodeId) ?? getNodeById?.(sourceNodeId); + const sourceOutput = sourceNode?.outputs.find((output) => output.id === outputId); + + return { + sourceNode, + sourceOutput, + sourceLabel: sourceNode + ? 'name' in sourceNode + ? sourceNode.name + : sourceNode.sourceLabel + : undefined + }; +}; + /** * 判断单项引用状态。输出可用性优先于来源范围,保证失效输出不会被误报为不可达。 - * 普通来源按 sourceNodeIds 或 sourceNodes 判断范围;global reference 单独按 chatConfig 查询。 + * 普通来源按 sourceNodes 判断范围;global reference 单独按 chatConfig 查询。 */ export const getWorkflowReferenceStatus = ({ value, valueType, - sourceNodeIds, sourceNodes, getNodeById, chatConfig @@ -57,8 +101,11 @@ export const getWorkflowReferenceStatus = ({ if (!isWorkflowReferenceItem(value)) return { code: 'invalid_reference' }; const [sourceNodeId, outputId] = value; - const sourceNode = - sourceNodes?.find((node) => node.nodeId === sourceNodeId) ?? getNodeById(sourceNodeId); + const source = getWorkflowReferenceSource({ + value, + sourceNodes, + getNodeById + }); if (sourceNodeId === VARIABLE_NODE_ID) { if (chatConfig !== undefined) { @@ -75,10 +122,10 @@ export const getWorkflowReferenceStatus = ({ return { code: 'valid', sourceType: globalVariable.valueType }; } - if (!sourceNode) return { code: 'valid' }; + if (!source.sourceNode) return { code: 'valid' }; } - const sourceOutput = sourceNode?.outputs.find((output) => output.id === outputId); + const { sourceNode, sourceOutput } = source; if (!sourceNode || !sourceOutput) return { code: 'invalid_reference' }; const selectableOutput = filterSelectableWorkflowNodeOutputs({ @@ -90,13 +137,10 @@ export const getWorkflowReferenceStatus = ({ return { code: 'invalid_reference', sourceType: sourceOutput.valueType }; } - const availableSourceNodeIds = sourceNodeIds - ? new Set(sourceNodeIds) - : sourceNodes && new Set(sourceNodes.map((node) => node.nodeId)); if ( sourceNodeId !== VARIABLE_NODE_ID && - availableSourceNodeIds && - !availableSourceNodeIds.has(sourceNodeId) + sourceNodes && + !sourceNodes.some((node) => node.nodeId === sourceNodeId) ) { return { code: 'unreachable_reference', sourceType: sourceOutput.valueType }; } @@ -128,6 +172,192 @@ export const getWorkflowReferenceStatuses = ({ return statuses; }; +/** 工作流变更后只保留仍被引用、且来源节点或输出已删除的历史展示快照。 */ +export const captureDeletedWorkflowReferenceSnapshots = ({ + previousNodes, + nextNodes, + previousChatConfig, + nextChatConfig, + globalVariableSourceLabel +}: { + previousNodes: Node[]; + nextNodes: Node[]; + previousChatConfig?: AppChatConfigType; + nextChatConfig?: AppChatConfigType; + globalVariableSourceLabel?: string; +}) => { + const buildSourceNodes = ( + nodes: Node[], + chatConfig: AppChatConfigType | undefined + ): WorkflowReferenceSourceNode[] => [ + ...nodes.map(({ data }) => ({ + nodeId: data.nodeId, + sourceLabel: data.name, + outputs: data.outputs, + catchError: data.catchError + })), + { + nodeId: VARIABLE_NODE_ID, + sourceLabel: globalVariableSourceLabel, + outputs: getWorkflowGlobalVariables({ chatConfig: chatConfig ?? {} }).map((variable) => ({ + id: variable.key, + key: variable.key, + type: FlowNodeOutputTypeEnum.static, + valueType: variable.valueType, + label: variable.label + })) + } + ]; + + const previousSourceNodes = buildSourceNodes(previousNodes, previousChatConfig); + const nextSourceNodes = buildSourceNodes(nextNodes, nextChatConfig); + + const updateOptional = (item: any, key: string, value: any) => { + if (isEqual(item[key], value)) return item; + + const nextItem = { ...item }; + if (value === undefined) { + delete nextItem[key]; + } else { + nextItem[key] = value; + } + return nextItem; + }; + + const getExistingSnapshot = ( + reference: ReferenceItemValueType, + snapshots?: WorkflowReferenceSnapshot[] + ) => + snapshots?.find( + (snapshot) => + isWorkflowReferenceItem(snapshot.reference) && + snapshot.reference[0] === reference[0] && + snapshot.reference[1] === reference[1] + ); + + const captureSnapshot = ( + reference: unknown, + existingSnapshot?: WorkflowReferenceSnapshot + ): WorkflowReferenceSnapshot | undefined => { + if (!isWorkflowReferenceItem(reference)) return undefined; + + const nextSource = getWorkflowReferenceSource({ + value: reference, + sourceNodes: nextSourceNodes + }); + if (nextSource.sourceOutput) return undefined; + + const previousSource = getWorkflowReferenceSource({ + value: reference, + sourceNodes: previousSourceNodes + }); + const source = previousSource.sourceOutput + ? { + sourceLabel: previousSource.sourceLabel, + outputLabel: previousSource.sourceOutput.label + } + : existingSnapshot + ? { + sourceLabel: existingSnapshot.sourceLabel, + outputLabel: existingSnapshot.outputLabel + } + : undefined; + + return source ? { reference, ...source } : undefined; + }; + + const captureSnapshots = (value: unknown, existingSnapshots?: WorkflowReferenceSnapshot[]) => { + const snapshots = getWorkflowReferenceItems(value) + .map((reference) => + captureSnapshot(reference, getExistingSnapshot(reference, existingSnapshots)) + ) + .filter((snapshot): snapshot is WorkflowReferenceSnapshot => !!snapshot); + + return snapshots.length > 0 ? snapshots : undefined; + }; + + const captureInput = (input: FlowNodeInputItemType): FlowNodeInputItemType => { + let nextInput: FlowNodeInputItemType = nodeInputIsReference(input) + ? updateOptional( + input, + 'referenceSnapshots', + captureSnapshots(input.value, input.referenceSnapshots) + ) + : updateOptional(input, 'referenceSnapshots', undefined); + + if (input.key === NodeInputKeyEnum.ifElseList && Array.isArray(input.value)) { + const nextValue = (input.value as IfElseListItemType[]).map((branch) => { + let branchChanged = false; + const nextList = branch.list.map((condition) => { + const nextCondition = updateOptional( + updateOptional( + condition, + 'variableSnapshot', + captureSnapshot(condition.variable, condition.variableSnapshot) + ), + 'valueSnapshot', + condition.valueType === 'reference' + ? captureSnapshot(condition.value, condition.valueSnapshot) + : undefined + ); + branchChanged ||= nextCondition !== condition; + return nextCondition; + }); + + if (!branchChanged) return branch; + return { ...branch, list: nextList }; + }); + + if ( + nextValue.some((branch, index) => branch !== (input.value as IfElseListItemType[])[index]) + ) { + nextInput = { ...nextInput, value: nextValue }; + } + } + + if (input.key === NodeInputKeyEnum.updateList && Array.isArray(input.value)) { + const nextValue = (input.value as TUpdateListItem[]).map((item) => { + let nextItem = updateOptional( + item, + 'variableSnapshot', + captureSnapshot(item.variable, item.variableSnapshot) + ); + nextItem = updateOptional( + nextItem, + 'valueReferenceSnapshots', + item.renderType === FlowNodeInputTypeEnum.reference + ? captureSnapshots(item.value, item.valueReferenceSnapshots) + : undefined + ); + return nextItem; + }); + + if (nextValue.some((item, index) => item !== (input.value as TUpdateListItem[])[index])) { + nextInput = { ...nextInput, value: nextValue }; + } + } + + return nextInput; + }; + + let changed = false; + const result = nextNodes.map((node) => { + const inputs = node.data.inputs.map(captureInput); + if (inputs.every((input, index) => input === node.data.inputs[index])) return node; + + changed = true; + return { + ...node, + data: { + ...node.data, + inputs + } + }; + }); + + return changed ? result : nextNodes; +}; + /** 将多项引用状态聚合为一个稳定的 checker issue code。 */ export const getWorkflowReferenceIssueCode = ( statuses: WorkflowReferenceStatus[] diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 96963f6f2de0..8425c7d3d900 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -433,6 +433,7 @@ export const filterWorkflowNodeOutputsByType = ( export type WorkflowReferenceSourceNode = { nodeId: string; + sourceLabel?: string; outputs: FlowNodeOutputItemType[]; catchError?: boolean; }; diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index 791520f39f52..6a826afccc0e 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -35,6 +35,7 @@ import { import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; import { getNodeAllSourceIds, + getRefData, isConfiguredReferenceValue, isEmptyReferenceValue, isWorkflowEdgeSourceHandleValid, @@ -367,6 +368,30 @@ const createWorkflowCheckContext = ({ }; }; +const getReferenceStatuses = ({ + value, + valueType, + sourceNodes, + context +}: { + value: unknown; + valueType?: WorkflowIOValueTypeEnum; + sourceNodes: FlowNodeItemType[]; + context: WorkflowCheckContext; +}) => { + const getNodeById = (nodeId: string | null | undefined) => + context.nodeMap.get(nodeId ?? '')?.data; + return getWorkflowReferenceStatuses({ + value, + valueType, + sourceNodes, + getNodeById, + chatConfig: context.chatConfig + }); +}; + +const isEmptyReferenceInputValue = (value: unknown) => isEmptyReferenceValue(value); + const isVariableUpdateValueEmpty = (item: TUpdateListItem) => { if (item.renderType === FlowNodeInputTypeEnum.reference) { return !isConfiguredReferenceValue(item.value); @@ -428,55 +453,31 @@ export const checkWorkflowNodeIssues = ({ const data = node.data; const inputs = data.inputs; const inputMap = new Map(inputs.map((input) => [input.key, input])); - const sourceNodeIds = getNodeAllSourceIds({ - nodeId: data.nodeId, - getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, - edges - }); - const getInputSourceNodeIds = (input: FlowNodeInputItemType) => { - if (data.flowNodeType !== FlowNodeTypeEnum.httpRequest468 || input.canEdit !== true) { - return sourceNodeIds; - } - - return getNodeAllSourceIds({ + const getSourceNodes = (sourceIds: string[]) => + sourceIds + .map((sourceId) => context.nodeMap.get(sourceId)?.data) + .filter((sourceNode): sourceNode is FlowNodeItemType => !!sourceNode); + const sourceNodes = getSourceNodes( + getNodeAllSourceIds({ nodeId: data.nodeId, getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, - edges, - includeChildren: true, - childrenNodeIdListMap: context.childrenNodeIdListMap - }); - }; - /** 统一引用状态、文案和字段路径,返回状态供后续类型校验复用。 */ - const addReferenceIssue = ({ - value, - valueType, - sourceNodeIds, - inputKey, - inputName - }: { - value: unknown; - valueType?: WorkflowIOValueTypeEnum; - sourceNodeIds: string[]; - inputKey: string; - inputName?: string; - }) => { - const statuses = getWorkflowReferenceStatuses({ - value, - valueType, - sourceNodeIds, - getNodeById: (nodeId) => context.nodeMap.get(nodeId ?? '')?.data, - chatConfig: context.chatConfig - }); - const code = getReferenceIssueCode(statuses); - if (code) { - addIssue({ - node, - code, - message: getWorkflowCheckIssueMessage(code, t, { inputName }), - inputKey - }); + edges + }) + ); + const getInputSourceNodes = (input: FlowNodeInputItemType) => { + if (data.flowNodeType !== FlowNodeTypeEnum.httpRequest468 || input.canEdit !== true) { + return sourceNodes; } - return statuses; + + return getSourceNodes( + getNodeAllSourceIds({ + nodeId: data.nodeId, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + edges, + includeChildren: true, + childrenNodeIdListMap: context.childrenNodeIdListMap + }) + ); }; const isToolNode = context.incomingEdgesMap .get(data.nodeId) @@ -550,26 +551,56 @@ export const checkWorkflowNodeIssues = ({ if (ifElseList) { ifElseList.forEach((branch, branchIndex) => { branch.list.forEach((condition, conditionIndex) => { - const variableInputKey = `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].variable`; - const valueInputKey = `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].value`; - const variableStatuses = addReferenceIssue({ + const addReferenceIssue = ({ + field, + code + }: { + field: 'variable' | 'value'; + code: 'invalid_reference' | 'invalid_reference_type' | 'unreachable_reference'; + }) => { + addIssue({ + node, + code, + message: getWorkflowCheckIssueMessage(code, t, { + inputName: field === 'variable' ? '变量' : '值' + }), + inputKey: `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].${field}` + }); + }; + + const variableStatuses = getReferenceStatuses({ value: condition.variable, - inputKey: variableInputKey, - inputName: '变量', - sourceNodeIds + sourceNodes, + context }); - const variableType = variableStatuses.find( - ({ sourceType }) => sourceType - )?.sourceType; + const variableIssueCode = getReferenceIssueCode(variableStatuses); + if (variableIssueCode) { + addReferenceIssue({ + field: 'variable', + code: variableIssueCode + }); + } + + const variableType = getRefData({ + variable: condition.variable, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + chatConfig: context.chatConfig + }).valueType; if (condition.valueType === 'reference') { - addReferenceIssue({ + const valueStatuses = getReferenceStatuses({ value: condition.value, - inputKey: valueInputKey, - inputName: '值', valueType: variableType, - sourceNodeIds + sourceNodes, + context }); + const valueIssueCode = getReferenceIssueCode(valueStatuses); + if (valueIssueCode) { + addReferenceIssue({ + field: 'value', + code: valueIssueCode + }); + } } }); }); @@ -763,14 +794,13 @@ export const checkWorkflowNodeIssues = ({ | 'invalid_reference_type' | 'unreachable_reference'; }) => { - const inputName = - field === 'variable' - ? t - ? t('common:core.workflow.variable' as any) - : '变量' - : t - ? t('common:value' as any) - : '值'; + const inputName = (() => { + if (field === 'variable') { + return t ? t('common:core.workflow.variable' as any) : '变量'; + } + + return t ? t('common:value' as any) : '值'; + })(); addIssue({ node, @@ -790,15 +820,17 @@ export const checkWorkflowNodeIssues = ({ addVariableUpdateIssue({ field: 'value', code: 'required_input_empty' }); } else { updateList.forEach((item, index) => { - const targetInputKey = `${NodeInputKeyEnum.updateList}[${index}].variable`; - const targetStatuses = addReferenceIssue({ + const targetStatuses = getReferenceStatuses({ value: item.variable, - inputKey: targetInputKey, - inputName: t ? t('common:core.workflow.variable' as any) : '变量', - sourceNodeIds + sourceNodes, + context }); const targetIssueCode = getReferenceIssueCode(targetStatuses); - const targetType = targetStatuses.find(({ sourceType }) => sourceType)?.sourceType; + const targetType = getRefData({ + variable: item.variable, + getNodeById: (targetNodeId) => context.nodeMap.get(targetNodeId ?? '')?.data, + chatConfig: context.chatConfig + }).valueType; if (!isConfiguredReferenceValue(item.variable)) { addVariableUpdateIssue({ @@ -806,8 +838,16 @@ export const checkWorkflowNodeIssues = ({ index, code: 'required_input_empty' }); + } else if (targetIssueCode) { + addVariableUpdateIssue({ + field: 'variable', + index, + code: targetIssueCode as + | 'invalid_reference' + | 'invalid_reference_type' + | 'unreachable_reference' + }); } else if ( - !targetIssueCode && item.valueType && !workflowValueTypeIsCompatible(item.valueType, targetType) ) { @@ -818,21 +858,31 @@ export const checkWorkflowNodeIssues = ({ }); } - if (item.renderType === FlowNodeInputTypeEnum.reference) { - addReferenceIssue({ - value: item.value, - inputKey: `${NodeInputKeyEnum.updateList}[${index}].value`, - inputName: t ? t('common:value' as any) : '值', - valueType: targetType, - sourceNodeIds - }); - } + const valueStatuses = + item.renderType === FlowNodeInputTypeEnum.reference + ? getReferenceStatuses({ + value: item.value, + valueType: targetType, + sourceNodes, + context + }) + : []; + const valueIssueCode = getReferenceIssueCode(valueStatuses); if (isVariableUpdateValueEmpty(item)) { addVariableUpdateIssue({ field: 'value', index, code: 'required_input_empty' }); + } else if (valueIssueCode) { + addVariableUpdateIssue({ + field: 'value', + index, + code: valueIssueCode as + | 'invalid_reference' + | 'invalid_reference_type' + | 'unreachable_reference' + }); } }); } @@ -855,13 +905,24 @@ export const checkWorkflowNodeIssues = ({ // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 const effectiveInputValue = input.value ?? input.defaultValue; - if (isReferenceInput) { - addReferenceIssue({ - value: effectiveInputValue, - valueType: input.valueType, - sourceNodeIds: getInputSourceNodeIds(input), - inputKey: input.key, - inputName: getInputLabel(input, t) + const referenceIssueCode = isReferenceInput + ? getReferenceIssueCode( + getReferenceStatuses({ + value: effectiveInputValue, + valueType: input.valueType, + sourceNodes: getInputSourceNodes(input), + context + }) + ) + : undefined; + if (referenceIssueCode) { + addIssue({ + node, + code: referenceIssueCode, + message: getWorkflowCheckIssueMessage(referenceIssueCode, t, { + inputName: getInputLabel(input, t) + }), + inputKey: input.key }); } @@ -885,7 +946,7 @@ export const checkWorkflowNodeIssues = ({ } const inputValueIsEmpty = isReferenceInput - ? isEmptyReferenceValue(effectiveInputValue) + ? isEmptyReferenceInputValue(effectiveInputValue) : isEmptyWorkflowInputValue(effectiveInputValue); if ( diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index def74d248ec7..957f7859ce5a 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -9,12 +9,57 @@ import { FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { + NodeInputKeyEnum, + VARIABLE_NODE_ID, + VariableInputEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { AppDetailType } from '@fastgpt/global/core/app/type'; import { CanonicalWorkflowDataSchema } from '@fastgpt/global/core/workflow/migration/schema'; import { PublishAppBodySchema } from '@fastgpt/global/openapi/core/app/version/api'; import { storeNode2FlowNode } from '@/web/core/workflow/utils'; +import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; + +const createReferenceInput = (key: string, value: unknown, referenceSnapshots?: unknown) => ({ + key, + label: key, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + valueType: WorkflowIOValueTypeEnum.any, + value, + ...(referenceSnapshots ? { referenceSnapshots } : {}) +}); + +const createNode = ({ + nodeId, + name, + inputs = [], + outputs = [] +}: { + nodeId: string; + name: string; + inputs?: any[]; + outputs?: any[]; +}) => + ({ + id: nodeId, + type: FlowNodeTypeEnum.userInput, + data: { + nodeId, + name, + inputs, + outputs + } + }) as any; + +const createOutput = (id: string, label: string) => ({ + id, + key: id, + label, + type: FlowNodeOutputTypeEnum.static +}); describe('WorkflowComponents utils', () => { describe('uiWorkflow2StoreWorkflow', () => { @@ -911,4 +956,188 @@ describe('WorkflowComponents utils', () => { expect(result.find((item) => item.key === 'userId')?.label).toBe('用户 ID'); }); }); + + describe('captureDeletedWorkflowReferenceSnapshots', () => { + it('captures deleted nodes and outputs, and clears a snapshot when the reference is valid', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + outputs: [createOutput('output', 'Output')] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [createReferenceInput('input', ['source', 'output'])] + }); + + const deletedNode = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer], + nextNodes: [consumer], + previousChatConfig: {}, + nextChatConfig: {} + }); + expect(deletedNode[0].data.inputs[0].referenceSnapshots).toEqual([ + { + reference: ['source', 'output'], + sourceLabel: 'Source', + outputLabel: 'Output' + } + ]); + + const deletedOutput = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer], + nextNodes: [createNode({ nodeId: 'source', name: 'Source' }), consumer], + previousChatConfig: {}, + nextChatConfig: {} + }); + expect(deletedOutput[1].data.inputs[0].referenceSnapshots).toEqual( + deletedNode[0].data.inputs[0].referenceSnapshots + ); + + const valid = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer], + nextNodes: [ + source, + createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [ + createReferenceInput( + 'input', + ['source', 'output'], + [ + { + reference: ['source', 'output'], + sourceLabel: 'Source', + outputLabel: 'Output' + } + ] + ) + ] + }) + ], + previousChatConfig: {}, + nextChatConfig: {} + }); + expect(valid[1].data.inputs[0]).not.toHaveProperty('referenceSnapshots'); + }); + + it('preserves existing snapshots and captures nested references', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + outputs: [createOutput('variable', 'Variable'), createOutput('value', 'Value')] + }); + const existingSnapshot = { + reference: ['missing', 'output'], + sourceLabel: 'Old source', + outputLabel: 'Old output' + }; + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [ + createReferenceInput('old', ['missing', 'output'], [existingSnapshot]), + { + key: NodeInputKeyEnum.ifElseList, + value: [ + { + condition: 'AND', + list: [ + { + variable: ['source', 'variable'], + value: ['source', 'value'], + valueType: 'reference' + } + ] + } + ] + }, + { + key: NodeInputKeyEnum.updateList, + value: [ + { + variable: ['source', 'variable'], + value: [ + ['source', 'value'], + ['source', 'variable'] + ], + renderType: FlowNodeInputTypeEnum.reference + } + ] + } + ] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer], + nextNodes: [consumer], + previousChatConfig: {}, + nextChatConfig: {} + }); + + expect(result[0].data.inputs[0].referenceSnapshots).toEqual([existingSnapshot]); + expect(result[0].data.inputs[1].value[0].list[0]).toMatchObject({ + variableSnapshot: { + reference: ['source', 'variable'], + sourceLabel: 'Source', + outputLabel: 'Variable' + }, + valueSnapshot: { + reference: ['source', 'value'], + sourceLabel: 'Source', + outputLabel: 'Value' + } + }); + expect(result[0].data.inputs[2].value[0]).toMatchObject({ + variableSnapshot: { + reference: ['source', 'variable'], + sourceLabel: 'Source', + outputLabel: 'Variable' + }, + valueReferenceSnapshots: [ + { + reference: ['source', 'value'], + sourceLabel: 'Source', + outputLabel: 'Value' + }, + { + reference: ['source', 'variable'], + sourceLabel: 'Source', + outputLabel: 'Variable' + } + ] + }); + }); + + it('captures a deleted global variable from the previous chat config', () => { + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [createReferenceInput('input', [VARIABLE_NODE_ID, 'deleted'])] + }); + const variable = { + key: 'deleted', + label: 'Deleted variable', + type: VariableInputEnum.input, + description: '' + }; + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [consumer], + nextNodes: [consumer], + previousChatConfig: { variables: [variable] }, + nextChatConfig: { variables: [] }, + globalVariableSourceLabel: 'Variable' + }); + + expect(result[0].data.inputs[0].referenceSnapshots).toEqual([ + { + reference: [VARIABLE_NODE_ID, 'deleted'], + sourceLabel: 'Variable', + outputLabel: 'Deleted variable' + } + ]); + }); + }); }); 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 187b1b717791..6f8a442b3e98 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -3273,7 +3273,7 @@ describe('workflow reference status', () => { expect( getWorkflowReferenceStatus({ value: ['source', 'invalid'], - sourceNodeIds: [], + sourceNodes: [], getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) }).code ).toBe('invalid_reference'); @@ -3283,39 +3283,16 @@ describe('workflow reference status', () => { expect( getWorkflowReferenceStatus({ value: ['source', 'error'], - sourceNodeIds: ['source'], + sourceNodes: [sourceNode], getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) }).code ).toBe('invalid_reference'); }); - it('derives reachability from sourceNodes when sourceNodeIds is omitted', () => { - expect( - getWorkflowReferenceStatus({ - value: ['source', 'text'], - sourceNodes: [ - { - nodeId: 'source', - outputs: [ - { - id: 'text', - key: 'text', - label: 'text', - type: FlowNodeOutputTypeEnum.static, - valueType: WorkflowIOValueTypeEnum.string - } - ] - } - ], - getNodeById: () => undefined - }).code - ).toBe('valid'); - }); - it('reports malformed items in mixed multiple references', () => { const statuses = getWorkflowReferenceStatuses({ value: [['source', 'error'], 'malformed'], - sourceNodeIds: ['source'], + sourceNodes: [sourceNode], getNodeById: (nodeId) => (nodeId === 'source' ? (sourceNode as any) : undefined) }); From bf30f2e5b438920a67cd22b0f0b135bdad960c2e Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 2 Sep 2026 14:55:17 +0800 Subject: [PATCH 13/18] fix(workflow): pass reference metadata --- .../common/Input/HttpInput/Editor.tsx | 5 ++++- .../common/Input/HttpInput/index.tsx | 4 ++++ .../common/Textarea/PromptEditor/Editor.tsx | 10 ++++++++-- .../components/core/app/formRender/index.tsx | 2 ++ .../components/core/app/formRender/type.ts | 2 ++ .../Flow/nodes/NodeAgent/index.tsx | 6 ++++-- .../Flow/nodes/NodeCode/Copilot.tsx | 5 +++-- .../Flow/nodes/NodeHttp/index.tsx | 19 +++++++++++++++---- .../NodeVariableUpdate/ValueRenderer.tsx | 1 + .../Flow/nodes/NodeVariableUpdate/index.tsx | 8 ++++++-- .../Flow/nodes/NodeWorkflowStart.tsx | 6 ++++-- .../RenderInput/templates/CommonInputForm.tsx | 9 ++++++--- projects/app/src/web/core/workflow/adapt.ts | 3 ++- 13 files changed, 61 insertions(+), 19 deletions(-) diff --git a/packages/web/components/common/Input/HttpInput/Editor.tsx b/packages/web/components/common/Input/HttpInput/Editor.tsx index 9ce32e565118..c006a2cf86bf 100644 --- a/packages/web/components/common/Input/HttpInput/Editor.tsx +++ b/packages/web/components/common/Input/HttpInput/Editor.tsx @@ -31,11 +31,13 @@ import VariableLabelPlugin from '../../Textarea/PromptEditor/plugins/VariableLab import { VariableLabelNode } from '../../Textarea/PromptEditor/plugins/VariableLabelPlugin/node'; import VariableLabelPickerPlugin from '../../Textarea/PromptEditor/plugins/VariableLabelPickerPlugin'; import { useDeepCompareEffect } from 'ahooks'; +import type { WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; export default function Editor({ h = 40, variables, variableLabels, + referenceSnapshots, onChange, onBlur, value, @@ -47,6 +49,7 @@ export default function Editor({ h?: number; variables: EditorVariablePickerType[]; variableLabels: EditorVariableLabelPickerType[]; + referenceSnapshots?: WorkflowReferenceSnapshot[]; onChange?: (editor: LexicalEditor) => void; onBlur?: (editor: LexicalEditor) => void; value?: string; @@ -134,7 +137,7 @@ export default function Editor({ }} /> - + diff --git a/packages/web/components/common/Input/HttpInput/index.tsx b/packages/web/components/common/Input/HttpInput/index.tsx index 6d94ce1c80e7..b1531d078df5 100644 --- a/packages/web/components/common/Input/HttpInput/index.tsx +++ b/packages/web/components/common/Input/HttpInput/index.tsx @@ -7,10 +7,12 @@ import { } from '../../Textarea/PromptEditor/type'; import Editor from './Editor'; import { editorStateToText } from '../../Textarea/PromptEditor/utils'; +import type { WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; const HttpInput = ({ variables = [], variableLabels = [], + referenceSnapshots, value, onChange, onBlur, @@ -22,6 +24,7 @@ const HttpInput = ({ }: { variables?: EditorVariablePickerType[]; variableLabels?: EditorVariableLabelPickerType[]; + referenceSnapshots?: WorkflowReferenceSnapshot[]; value?: string; onChange?: (text: string) => void; onBlur?: (text: string) => void; @@ -51,6 +54,7 @@ const HttpInput = ({ Promise; onRemoveToolFromEditor?: (toolId: string) => void; onConfigureTool?: (toolId: string) => void; @@ -116,6 +118,7 @@ export default function Editor({ variables = [], // /选择变量 variableLabels = [], + referenceSnapshots, // @选择技能 skillOption, selectedSkills, @@ -170,7 +173,7 @@ export default function Editor({ useDeepCompareEffect(() => { if (focus && value === editorOutputRef.current) return; setKey(getNanoid(6)); - }, [value, variables, variableLabels]); + }, [referenceSnapshots, value, variables, variableLabels]); const showFullScreenIcon = useMemo(() => { return showOpenModal && scrollHeight > maxH; @@ -281,7 +284,10 @@ export default function Editor({ {variableLabels.length > 0 && ( <> - + )} diff --git a/projects/app/src/components/core/app/formRender/index.tsx b/projects/app/src/components/core/app/formRender/index.tsx index aa1587021e53..3a3ffa31012d 100644 --- a/projects/app/src/components/core/app/formRender/index.tsx +++ b/projects/app/src/components/core/app/formRender/index.tsx @@ -74,6 +74,7 @@ const InputRender = (props: InputRenderProps) => { {...commonProps} variables={props.variables} variableLabels={props.variableLabels} + referenceSnapshots={props.referenceSnapshots} title={props.title} maxLength={props.maxLength} minH={40} @@ -90,6 +91,7 @@ const InputRender = (props: InputRenderProps) => { isRichText={props.isRichText} variables={props.variables} variableLabels={props.variableLabels} + referenceSnapshots={props.referenceSnapshots} title={props.title} maxLength={props.maxLength} minH={100} diff --git a/projects/app/src/components/core/app/formRender/type.ts b/projects/app/src/components/core/app/formRender/type.ts index 62d799cd8ebe..74ae72dd567e 100644 --- a/projects/app/src/components/core/app/formRender/type.ts +++ b/projects/app/src/components/core/app/formRender/type.ts @@ -7,6 +7,7 @@ import type { UseFormReturn } from 'react-hook-form'; import type { BoxProps, MenuProps } from '@chakra-ui/react'; import type { EditorProps } from '@fastgpt/web/components/common/Textarea/PromptEditor/Editor'; import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; +import type { WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; export type CommonRenderProps = { placeholder?: string; @@ -25,6 +26,7 @@ export type SpecificProps = { // input & textarea variables?: EditorVariablePickerType[]; variableLabels?: EditorVariableLabelPickerType[]; + referenceSnapshots?: WorkflowReferenceSnapshot[]; title?: string; maxLength?: number; isRichText?: boolean; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx index 215cdf94dac0..97c9183fbf4a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx @@ -99,7 +99,7 @@ const NodeAgent = ({ data, selected }: NodeProps) => { const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const { splitToolInputs, splitOutput } = useContextSelector(WorkflowUtilsContext, (ctx) => ctx); - const { getNodeById, edges, llmMaxQuoteContext } = useContextSelector( + const { getNodeById, getNodeList, edges, llmMaxQuoteContext } = useContextSelector( WorkflowBufferDataContext, (v) => v ); @@ -126,12 +126,13 @@ const NodeAgent = ({ data, selected }: NodeProps) => { () => getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, t }), - [nodeId, getNodeById, edges, appDetail, t] + [nodeId, getNodeById, getNodeList, edges, appDetail, t] ); const externalVariables = useMemo( () => @@ -480,6 +481,7 @@ const NodeAgent = ({ data, selected }: NodeProps) => { onChange={onPromptChange} variables={allVariables} variableLabels={editorVariables} + referenceSnapshots={promptInput.referenceSnapshots} skillOption={skillOption} selectedSkills={selectedSkills} onClickSkill={onClickSkill} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx index a0e76714ed50..0eeaa8b431ed 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx @@ -58,7 +58,7 @@ const NodeCopilot = ({ const { t } = useTranslation(); const { toast } = useToast(); const { llmModelList, defaultModels } = useSystemStore(); - const { edges, getNodeById } = useContextSelector( + const { edges, getNodeById, getNodeList } = useContextSelector( WorkflowBufferDataContext, (v) => v ); @@ -77,12 +77,13 @@ const NodeCopilot = ({ const editorVariables = useMemoEnhance(() => { return getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, t }).filter((item) => item.parent.id !== nodeId); - }, [nodeId, getNodeById, edges, appDetail, t]); + }, [nodeId, getNodeById, getNodeList, edges, appDetail, t]); const { codeType, code, dynamicInputs, dynamicOutputs } = useMemo(() => { const codeTypeInput = realTimeInputs?.find((input) => input.key === NodeInputKeyEnum.codeType); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx index 8282541f8641..3c4b4534e6f0 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx @@ -93,7 +93,10 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({ const { t } = useTranslation(); const { toast } = useToast(); - const { edges, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); + const { edges, getNodeById, getNodeList } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const { appDetail } = useContextSelector(AppContext, (v) => v); @@ -172,12 +175,13 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({ const variables = useMemoEnhance(() => { return getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, t }); - }, [nodeId, getNodeById, edges, appDetail, t]); + }, [nodeId, getNodeById, getNodeList, edges, appDetail, t]); const externalProviderWorkflowVariables = useMemo(() => { return ( @@ -234,6 +238,7 @@ const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({ } value={requestUrl?.value || ''} variableLabels={variables} + referenceSnapshots={requestUrl?.referenceSnapshots} variables={externalProviderWorkflowVariables} onBlur={onBlurUrl} onChange={onChangeUrl} @@ -259,7 +264,7 @@ export function RenderHttpProps({ const [selectedTab, setSelectedTab] = useState(TabEnum.params); const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges); - const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); + const { getNodeById, getNodeList } = useContextSelector(WorkflowBufferDataContext, (v) => v); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const { appDetail } = useContextSelector(AppContext, (v) => v); @@ -290,12 +295,13 @@ export function RenderHttpProps({ const variables = useMemoEnhance(() => { return getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, t }); - }, [nodeId, getNodeById, edges, appDetail, t]); + }, [nodeId, getNodeById, getNodeList, edges, appDetail, t]); const variableText = useMemo(() => { return variables @@ -624,6 +630,7 @@ const RenderForm = ({ placeholder={t('common:textarea_variable_picker_tip')} value={item.key} variableLabels={variables} + referenceSnapshots={input.referenceSnapshots} variables={externalProviderWorkflowVariables} tabIndex={0} resetOnValueChange={false} @@ -636,6 +643,7 @@ const RenderForm = ({ value={item.value} variables={externalProviderWorkflowVariables} variableLabels={variables} + referenceSnapshots={input.referenceSnapshots} tabIndex={0} resetOnValueChange={false} onBlur={(val) => handleRowBlur(index, 'value', val)} @@ -680,6 +688,7 @@ const RenderForm = ({ handleDelete, handleRowBlur, input.key, + input.referenceSnapshots, list, rowKeys, t, @@ -787,6 +796,7 @@ const RenderBody = ({ bg={'white'} showOpenModal={false} variableLabels={variables} + referenceSnapshots={jsonBody.referenceSnapshots} minH={200} value={jsonBody.value} placeholder={t('workflow:http_body_placeholder')} @@ -820,6 +830,7 @@ const RenderBody = ({ }} showOpenModal={false} variableLabels={variables} + referenceSnapshots={jsonBody.referenceSnapshots} minH={200} /> )} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx index f0ab4be4cbca..3dd03ada9070 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/ValueRenderer.tsx @@ -139,6 +139,7 @@ const ValueRenderer: React.FC = (props) => { {...stringFormParams} variables={variables} variableLabels={variableLabels} + referenceSnapshots={valueReferenceSnapshots} value={value?.[1]} onChange={(v: unknown) => onChange({ value: ['', v] as ReferenceValueType })} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx index ec805d9d9242..9ed559dba44c 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/index.tsx @@ -60,18 +60,22 @@ const NodeVariableUpdate = ({ data, selected }: NodeProps) => const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { edges, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); + const { edges, getNodeById, getNodeList } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); const appDetail = useContextSelector(AppContext, (v) => v.appDetail); const variables = useMemoEnhance(() => { return getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, t }); - }, [nodeId, getNodeById, edges, appDetail, t]); + }, [nodeId, getNodeById, getNodeList, edges, appDetail, t]); const { feConfigs } = useSystemStore(); const externalProviderWorkflowVariables = useMemo(() => { return ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx index e089702f04ee..24be122df712 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeWorkflowStart.tsx @@ -40,7 +40,8 @@ const NodeStart = ({ data, selected }: NodeProps) => { required: item.required, valueType: item.valueType || WorkflowIOValueTypeEnum.any, label: t(item.label as any), - valueDesc: item.valueDesc + valueDesc: item.valueDesc, + ...(item.icon ? { icon: item.icon } : {}) }; }); }, [appDetail.chatConfig, t]); @@ -54,7 +55,8 @@ const NodeStart = ({ data, selected }: NodeProps) => { required: item.required, valueType: item.valueType || WorkflowIOValueTypeEnum.any, label: t(item.label as any), - valueDesc: item.valueDesc + valueDesc: item.valueDesc, + ...(item.icon ? { icon: item.icon } : {}) })), [t] ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx index 6b5fa69c8999..c139f0a67f7f 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx @@ -21,7 +21,7 @@ import { getSelectedInputRenderType } from '@fastgpt/global/core/workflow/utils' const CommonInputForm = ({ item, nodeId }: RenderInputProps) => { const { t } = useTranslation(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); - const { getNodeById, edges } = useContextSelector( + const { getNodeById, getNodeList, edges } = useContextSelector( WorkflowBufferDataContext, (v) => v ); @@ -38,12 +38,14 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => { const editorVariables = useMemoEnhance(() => { return getEditorVariables({ nodeId, + nodeList: getNodeList(), getNodeById, edges, appDetail, - t + t, + valueType: item.valueType }); - }, [nodeId, getNodeById, edges, appDetail, t]); + }, [nodeId, getNodeById, getNodeList, edges, appDetail, t, item.valueType]); const externalVariables = useMemo(() => { return ( @@ -119,6 +121,7 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => { onChange={handleChange} variables={[...(editorVariables || []), ...(externalVariables || [])]} variableLabels={editorVariables} + referenceSnapshots={item.referenceSnapshots} modelList={llmModelList} ExtensionPopover={canOptimizePrompt ? [OptimizerPopverComponent] : undefined} menuPlacement={menuPlacement} diff --git a/projects/app/src/web/core/workflow/adapt.ts b/projects/app/src/web/core/workflow/adapt.ts index 41c023331754..56d18106c740 100644 --- a/projects/app/src/web/core/workflow/adapt.ts +++ b/projects/app/src/web/core/workflow/adapt.ts @@ -46,7 +46,8 @@ export const getGlobalVariableNode = ({ type: FlowNodeOutputTypeEnum.static, label: item.label, key: item.key, - valueType: item.valueType || WorkflowIOValueTypeEnum.any + valueType: item.valueType || WorkflowIOValueTypeEnum.any, + icon: item.icon })) }; From 104f8c9f226dd739f071910f9dab647fa221ff57 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 2 Sep 2026 16:04:57 +0800 Subject: [PATCH 14/18] fix(workflow): preserve reference labels --- packages/global/core/workflow/type/io.ts | 7 +- packages/global/core/workflow/utils.ts | 11 +- .../global/test/core/workflow/utils.test.ts | 4 +- .../VariableLabelPickerPlugin/index.tsx | 20 +- .../components/VariableLabel.tsx | 30 +- .../plugins/VariableLabelPlugin/index.tsx | 79 +++- .../plugins/VariableLabelPlugin/node.tsx | 43 +- .../common/Textarea/PromptEditor/type.ts | 2 +- .../RenderInput/templates/Reference.tsx | 68 +++- .../app/detail/WorkflowComponents/utils.ts | 108 +++-- .../src/web/core/workflow/referenceCheck.ts | 112 +++++- projects/app/src/web/core/workflow/utils.ts | 1 + .../detail/WorkflowComponents/utils.test.ts | 380 +++++++++++++++++- 13 files changed, 744 insertions(+), 121 deletions(-) diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 76fefc2d5d99..afe9f78c25b7 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -61,7 +61,9 @@ export type ReferenceValueType = z.infer; export const WorkflowReferenceSnapshotSchema = z.object({ reference: ReferenceItemValueTypeSchema, sourceLabel: z.string().optional(), - outputLabel: z.string().optional() + outputLabel: z.string().optional(), + // 来源节点图标;变量自身图标不进入 snapshot。 + icon: z.string().optional() }); export type WorkflowReferenceSnapshot = z.infer; @@ -355,6 +357,9 @@ export const FlowNodeOutputItemTypeSchema = z.object({ valueType: z.enum(WorkflowIOValueTypeEnum).optional().meta({ description: '节点输出值的数据类型' }), + icon: z.string().optional().meta({ + description: '节点输出变量图标' + }), valueDesc: z.string().optional().meta({ description: '输出值说明,通常用于展示引用值含义' }), diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 2fe4c4f6d1be..6b9edc3a081c 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -466,10 +466,13 @@ export const toolSetData2FlowNodeIO = ({ nodes }: { nodes: StoreNodeItemType[] } export const formatEditorVariablePickerIcon = ( variables: { key: string; label: string; type?: `${VariableInputEnum}`; required?: boolean }[] ): EditorVariablePickerType[] => { - return variables.map((item) => ({ - ...item, - icon: item.type ? variableMap[item.type]?.icon : variableMap['input'].icon - })); + return variables.map((item) => { + const icon = item.type ? variableMap[item.type]?.icon : undefined; + return { + ...item, + ...(icon ? { icon } : {}) + }; + }); }; // Check the value is a valid reference value format: [variableId, outputId] diff --git a/packages/global/test/core/workflow/utils.test.ts b/packages/global/test/core/workflow/utils.test.ts index ed5a7bcdfe5e..193c7b4aae4c 100644 --- a/packages/global/test/core/workflow/utils.test.ts +++ b/packages/global/test/core/workflow/utils.test.ts @@ -1093,10 +1093,10 @@ describe('formatEditorVariablePickerIcon', () => { expect(result[0].label).toBe('Variable 1'); }); - it('should use default input icon when type is undefined', () => { + it('should leave icon undefined when type is undefined', () => { const variables = [{ key: 'var1', label: 'Variable 1' }]; const result = formatEditorVariablePickerIcon(variables); - expect(result[0].icon).toBeDefined(); + expect(result[0].icon).toBeUndefined(); }); it('should preserve required field', () => { diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPickerPlugin/index.tsx index c4412b493114..e46cd8a9aaf5 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPickerPlugin/index.tsx @@ -16,7 +16,6 @@ interface EditorVariableItemType { key: string; label: string; required?: boolean; - icon?: string; valueType?: WorkflowIOValueTypeEnum; index: number; } @@ -36,6 +35,7 @@ export default function VariableLabelPickerPlugin({ }) { const { t } = useSafeTranslation(); const [editor] = useLexicalComposerContext(); + const selectableVariables = variables.filter((item) => !item.invalidReason); const [queryString, setQueryString] = useState(null); const [currentIndex, setCurrentIndex] = useState(0); const highlightedItemRef = useRef(null); @@ -81,7 +81,7 @@ export default function VariableLabelPickerPlugin({ onQueryChange={setQueryString} onSelectOption={onSelectOption} triggerFn={checkForTriggerMatch} - options={variableFilter(variables, queryString || '')} + options={variableFilter(selectableVariables, queryString || '')} menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp }) => { if (anchorElementRef.current == null) { return null; @@ -89,7 +89,7 @@ export default function VariableLabelPickerPlugin({ if (currentIndex !== selectedIndex) { setCurrentIndex(selectedIndex || 0); } - return anchorElementRef.current && variables.length && isFocus + return anchorElementRef.current && selectableVariables.length && isFocus ? ReactDOM.createPortal( - {variableFilter(variables, queryString || '').length === variables.length && ( + {variableFilter(selectableVariables, queryString || '').length === + selectableVariables.length && ( {t('workflow:variable_picker_tips')} )} - {variableFilter(variables, queryString || '').length > 0 ? ( - transformVariables(variableFilter(variables, queryString || '')).map((item) => { - return ( + {variableFilter(selectableVariables, queryString || '').length > 0 ? ( + transformVariables(variableFilter(selectableVariables, queryString || '')).map( + (item) => ( ))} - ); - }) + ) + ) ) : ( {t('common:unusable_variable')} @@ -210,7 +211,6 @@ function transformVariables(variables: EditorVariableLabelPickerType[]): Transfo parentMap[parentId].children.push({ label: item.label, key: item.key, - icon: item.icon, index }); }); diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx index 30c3ea6b2f8c..a2d54178fd2e 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/components/VariableLabel.tsx @@ -2,21 +2,31 @@ import { ChevronRightIcon } from '@chakra-ui/icons'; import { Box, Flex } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import Avatar from '../../../../../../../components/common/Avatar'; +import MyTooltip from '../../../../../MyTooltip'; export default function VariableLabel({ variableLabel, - nodeAvatar + nodeAvatar, + invalidReason }: { variableLabel: string; nodeAvatar: string; + invalidReason?: 'invalid_reference' | 'unreachable_reference' | 'invalid_reference_type'; }) { const { t } = useTranslation(); // avoid including '.' in the variable name. const [parentLabel, ...childLabels] = variableLabel.split('.'); const childLabel = childLabels.join('.'); - const isInvalid = parentLabel === 'undefined'; - - return ( + const isInvalid = parentLabel === 'undefined' || !!invalidReason; + const invalidReasonLabel = + invalidReason === 'invalid_reference' + ? t('common:core.workflow.check.reference_deleted') + : invalidReason === 'unreachable_reference' + ? t('common:core.workflow.check.reference_unreachable') + : invalidReason === 'invalid_reference_type' + ? t('common:core.workflow.check.reference_type_mismatch') + : t('common:invalid_variable'); + const label = ( <> - {!isInvalid ? ( - - + {parentLabel !== 'undefined' ? ( + + {nodeAvatar && } {parentLabel} {childLabel} ) : ( - {t('common:invalid_variable')} + + {t('common:invalid_variable')} + )} ); + + return isInvalid ? {label} : label; } diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/index.tsx index 3a8d77760940..4e1704f6fb94 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/index.tsx @@ -2,18 +2,26 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { type EditorVariableLabelPickerType } from '../../type'; import { useCallback, useEffect } from 'react'; import { $createVariableLabelNode, VariableLabelNode } from './node'; -import type { TextNode } from 'lexical'; +import { $nodesOfType, type TextNode } from 'lexical'; import { getHashtagRegexString } from './utils'; import { mergeRegister } from '@lexical/utils'; import { registerLexicalTextEntity } from '../../utils'; import { useSafeTranslation } from '../../../../../../hooks/useSafeTranslation'; +import type { WorkflowReferenceSnapshot } from '@fastgpt/global/core/workflow/type/io'; const REGEX = new RegExp(getHashtagRegexString(), 'i'); +type VariableLabelData = { + variableLabel: string; + nodeAvatar: string; + invalidReason?: EditorVariableLabelPickerType['invalidReason']; +}; export default function VariableLabelPlugin({ - variables + variables, + referenceSnapshots }: { variables: EditorVariableLabelPickerType[]; + referenceSnapshots?: WorkflowReferenceSnapshot[]; }) { const { t } = useSafeTranslation(); const [editor] = useLexicalComposerContext(); @@ -22,21 +30,53 @@ export default function VariableLabelPlugin({ throw new Error('VariableLabelPlugin: VariableLabelPlugin not registered on editor'); }, [editor]); + const getVariableLabelData = useCallback( + (variableKey: string): VariableLabelData => { + const content = variableKey.slice(3, -3); + const [parentKey, ...childKeys] = content.split('.'); + const childKey = childKeys.join('.') || content; + const currentVariable = variables.find( + (item) => item.parent.id === parentKey && item.key === childKey + ); + const snapshot = referenceSnapshots?.find( + (item) => item.reference[0] === parentKey && item.reference[1] === childKey + ); + + return currentVariable + ? { + variableLabel: `${t(currentVariable.parent.label as any)}.${currentVariable.label}`, + nodeAvatar: currentVariable.parent.avatar || '', + invalidReason: currentVariable.invalidReason + } + : snapshot + ? { + variableLabel: `${snapshot.sourceLabel || 'undefined'}.${ + snapshot.outputLabel ? t(snapshot.outputLabel as any) : childKey + }`, + nodeAvatar: snapshot.icon || '', + invalidReason: 'invalid_reference' + } + : { + variableLabel: `undefined.${childKey}`, + nodeAvatar: '' + }; + }, + [referenceSnapshots, t, variables] + ); + const createVariableLabelPlugin = useCallback( (textNode: TextNode): VariableLabelNode => { - const content = textNode.getTextContent().slice(3, -3); // Remove {{$ and $}} - const dotIndex = content.indexOf('.'); - const parentKey = content.slice(0, dotIndex); - const childrenKey = content.slice(dotIndex + 1); - - const currentVariable = variables.find( - (item) => item.parent.id === parentKey && item.key === childrenKey + const { variableLabel, nodeAvatar, invalidReason } = getVariableLabelData( + textNode.getTextContent() + ); + return $createVariableLabelNode( + textNode.getTextContent(), + variableLabel, + nodeAvatar, + invalidReason ); - const variableLabel = `${currentVariable && t(currentVariable.parent?.label as any)}.${currentVariable?.label}`; - const nodeAvatar = currentVariable?.parent?.avatar || ''; - return $createVariableLabelNode(textNode.getTextContent(), variableLabel, nodeAvatar); }, - [t, variables] + [getVariableLabelData] ); const getVariableMatch = useCallback((text: string) => { @@ -63,5 +103,18 @@ export default function VariableLabelPlugin({ ); }, [createVariableLabelPlugin, editor, getVariableMatch]); + useEffect(() => { + editor.update(() => { + $nodesOfType(VariableLabelNode).forEach((node) => { + const { variableLabel, nodeAvatar, invalidReason } = getVariableLabelData( + node.getVariableKey() + ); + node.setVariableLabel(variableLabel); + node.setNodeAvatar(nodeAvatar); + node.setInvalidReason(invalidReason); + }); + }); + }, [editor, getVariableLabelData]); + return null; } diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/node.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/node.tsx index 03f0ae80eddb..5b6a8c3852cd 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/node.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/VariableLabelPlugin/node.tsx @@ -11,6 +11,9 @@ import { type TextFormatType } from 'lexical'; import VariableLabel from './components/VariableLabel'; +import type { EditorVariableLabelPickerType } from '../../type'; + +type VariableLabelInvalidReason = EditorVariableLabelPickerType['invalidReason']; export type SerializedVariableLabelNode = Spread< { @@ -27,6 +30,7 @@ export class VariableLabelNode extends DecoratorNode { __variableKey: string; __variableLabel: string; __nodeAvatar: string; + __invalidReason?: VariableLabelInvalidReason; static getType(): string { return 'variableLabel'; } @@ -36,7 +40,8 @@ export class VariableLabelNode extends DecoratorNode { node.__variableLabel, node.__nodeAvatar, node.__format, - node.__key + node.__key, + node.__invalidReason ); } constructor( @@ -44,13 +49,15 @@ export class VariableLabelNode extends DecoratorNode { variableLabel: string, nodeAvatar: string, format?: number | TextFormatType, - key?: NodeKey + key?: NodeKey, + invalidReason?: VariableLabelInvalidReason ) { super(key); this.__variableKey = variableKey; this.__format = format || 0; this.__variableLabel = variableLabel; this.__nodeAvatar = nodeAvatar; + this.__invalidReason = invalidReason; } static importJSON(serializedNode: SerializedVariableLabelNode): VariableLabelNode { @@ -98,6 +105,18 @@ export class VariableLabelNode extends DecoratorNode { getVariableKey(): string { return this.__variableKey; } + setVariableLabel(variableLabel: string): void { + if (this.__variableLabel === variableLabel) return; + this.getWritable().__variableLabel = variableLabel; + } + setNodeAvatar(nodeAvatar: string): void { + if (this.__nodeAvatar === nodeAvatar) return; + this.getWritable().__nodeAvatar = nodeAvatar; + } + setInvalidReason(invalidReason?: VariableLabelInvalidReason): void { + if (this.__invalidReason === invalidReason) return; + this.getWritable().__invalidReason = invalidReason; + } getTextContent( _includeInert?: boolean | undefined, _includeDirectionless?: false | undefined @@ -105,16 +124,30 @@ export class VariableLabelNode extends DecoratorNode { return `${this.__variableKey}`; } decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element { - return ; + return ( + + ); } } export function $createVariableLabelNode( variableKey: string, variableLabel: string, - nodeAvatar: string + nodeAvatar: string, + invalidReason?: VariableLabelInvalidReason ): VariableLabelNode { - return new VariableLabelNode(variableKey, variableLabel, nodeAvatar); + return new VariableLabelNode( + variableKey, + variableLabel, + nodeAvatar, + undefined, + undefined, + invalidReason + ); } export function $isVariableLabelNode( diff --git a/packages/web/components/common/Textarea/PromptEditor/type.ts b/packages/web/components/common/Textarea/PromptEditor/type.ts index ec25f02a1bce..259b5ad7e226 100644 --- a/packages/web/components/common/Textarea/PromptEditor/type.ts +++ b/packages/web/components/common/Textarea/PromptEditor/type.ts @@ -15,8 +15,8 @@ export type EditorVariableLabelPickerType = { key: string; label: string; required?: boolean; - icon?: string; valueType?: WorkflowIOValueTypeEnum; + invalidReason?: 'invalid_reference' | 'unreachable_reference' | 'invalid_reference_type'; parent: { id: string; label: string; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 084c650cf580..9f6cd3d97a7d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -47,6 +47,7 @@ type ReferenceSelectList = { label: string | React.ReactNode; value: string; sourceLabel?: string; + icon?: string; children: { label: string; value: string; @@ -65,11 +66,6 @@ const getReferenceValueTitle = (value: unknown) => { return typeof value === 'string' ? value : undefined; }; -const getReferenceOutputKey = (value: unknown) => { - if (Array.isArray(value) && typeof value[1] === 'string') return value[1]; - return typeof value === 'string' ? value : undefined; -}; - const getReferenceListItem = (value: unknown, list: ReferenceSelectList) => { if (!isWorkflowReferenceItem(value)) return; @@ -78,9 +74,15 @@ const getReferenceListItem = (value: unknown, list: ReferenceSelectList) => { return source && output ? { source, output } : undefined; }; -const getSelectedReferenceLabels = (value: unknown, list: ReferenceSelectList) => { +const getSelectedReference = (value: unknown, list: ReferenceSelectList) => { const reference = getReferenceListItem(value, list); - return reference ? [reference.source.label, reference.output.label] : []; + return reference + ? { + sourceLabel: reference.source.sourceLabel, + outputLabel: reference.output.label, + sourceIcon: reference.source.icon + } + : {}; }; /** 按当前选项或历史快照恢复失效引用的展示标签。 */ @@ -104,11 +106,12 @@ const getReferenceSnapshot = ({ return { reference: value, sourceLabel: reference.source.sourceLabel, - outputLabel: reference.output.outputLabel + outputLabel: reference.output.outputLabel, + icon: reference.source.icon }; } - const { sourceLabel, sourceOutput } = getWorkflowReferenceSource({ + const { sourceLabel, sourceOutput, sourceIcon } = getWorkflowReferenceSource({ value, sourceNodes, getNodeById @@ -117,7 +120,8 @@ const getReferenceSnapshot = ({ return { reference: value, sourceLabel, - outputLabel: sourceOutput.label + outputLabel: sourceOutput.label, + icon: sourceIcon }; } @@ -142,13 +146,16 @@ const getInvalidReferenceReason = ( const ReferenceOutputLabel = ({ sourceLabel, outputLabel, + sourceIcon, iconMargin = 0.5 }: { sourceLabel: React.ReactNode; outputLabel: React.ReactNode; + sourceIcon?: string; iconMargin?: number; }) => ( <> + {sourceIcon && } {sourceLabel} {outputLabel} @@ -170,6 +177,7 @@ const ReferenceSnapshotLabel = ({ if (sourceLabel && outputLabel) { return ( <> + {snapshot?.icon && } {sourceLabel} {outputLabel} @@ -177,7 +185,15 @@ const ReferenceSnapshotLabel = ({ ); } - return <>{outputLabel || sourceLabel || fallback}; + const label = outputLabel || sourceLabel || fallback; + return ( + <> + {label && snapshot?.icon && ( + + )} + {label} + + ); }; type CommonSelectProps = { @@ -247,6 +263,7 @@ export const useReference = ({ label, value: node.nodeId, sourceLabel: node.name, + icon: node.avatar, children: selectableOutputs.map((output) => ({ label: t(output.label as any), value: output.id, @@ -262,6 +279,7 @@ export const useReference = ({ sourceNodes: sourceNodes.map((node) => ({ nodeId: node.nodeId, sourceLabel: node.name, + icon: node.avatar, outputs: node.outputs, catchError: node.catchError })) @@ -349,7 +367,11 @@ const SingleReferenceSelector = ({ const getNodeById = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodeById); const selectorVal = value as ReferenceItemValueType; - const [nodeName, outputName] = getSelectedReferenceLabels(selectorVal, list); + const { + sourceLabel: nodeName, + outputLabel: outputName, + sourceIcon + } = getSelectedReference(selectorVal, list); const status = getWorkflowReferenceStatus({ value: selectorVal, valueType, @@ -358,7 +380,6 @@ const SingleReferenceSelector = ({ }); const isValidSelect = status.code === 'valid' && Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(selectorVal) && !isValidSelect; - const referenceOutputKey = getReferenceOutputKey(selectorVal); const referenceTitle = getReferenceValueTitle(selectorVal); const invalidReason = getInvalidReferenceReason(status.code, t); const invalidSnapshot = isInvalidReference @@ -376,14 +397,18 @@ const SingleReferenceSelector = ({ label={ isValidSelect ? ( - + ) : isInvalidReference ? ( @@ -439,7 +464,11 @@ const MultipleReferenceSelector = ({ if (!Array.isArray(value)) return []; return value.map((item) => { - const [nodeName, outputName] = getSelectedReferenceLabels(item, list); + const { + sourceLabel: nodeName, + outputLabel: outputName, + sourceIcon + } = getSelectedReference(item, list); const status = getWorkflowReferenceStatus({ value: item, valueType, @@ -450,6 +479,7 @@ const MultipleReferenceSelector = ({ rawValue: item, nodeName, outputName, + sourceIcon, status }; }); @@ -477,10 +507,9 @@ const MultipleReferenceSelector = ({ } }} > - {formatList.map(({ rawValue, nodeName, outputName, status }, index) => { + {formatList.map(({ rawValue, nodeName, outputName, sourceIcon, status }, index) => { const isValidReference = status.code === 'valid' && Boolean(nodeName && outputName); const isInvalidReference = isConfiguredReferenceValue(rawValue) && !isValidReference; - const referenceOutputKey = getReferenceOutputKey(rawValue); const referenceTitle = getReferenceValueTitle(rawValue); const invalidReason = getInvalidReferenceReason(status.code, t); const invalidSnapshot = isInvalidReference @@ -513,12 +542,13 @@ const MultipleReferenceSelector = ({ ) : isInvalidReference ? ( ) : ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts index 6441c4a79e31..8dfdf7f26e1d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/utils.ts @@ -1,4 +1,5 @@ import { filterSelectableWorkflowNodeOutputs, getNodeAllSource } from '@/web/core/workflow/utils'; +import { getWorkflowReferenceStatus } from '@/web/core/workflow/referenceCheck'; import { workflowSystemVariables } from '@/web/core/app/utils'; import { type AppChatConfigType, type AppDetailType } from '@fastgpt/global/core/app/type'; import { @@ -18,9 +19,11 @@ import { } from '@fastgpt/global/core/workflow/utils'; import { normalizeFlowNodeInputType, - serializeAgentTool + serializeAgentTool, + isToolParamInput } from '@fastgpt/global/core/app/formEdit/utils'; import { SelectedToolItemTypeSchema } from '@fastgpt/global/core/app/formEdit/type'; +import type { EditorVariableLabelPickerType } from '@fastgpt/web/components/common/Textarea/PromptEditor/type'; import { type TFunction } from 'i18next'; import { type Edge, type Node } from 'reactflow'; @@ -130,25 +133,34 @@ export const filterExportModules = (modules: StoreNodeItemType[]) => { export const getEditorVariables = ({ nodeId, + nodeList, getNodeById, edges, appDetail, - t + t, + valueType }: { nodeId: string; + nodeList?: FlowNodeItemType[]; getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined; edges: Edge[]; appDetail: AppDetailType; t: TFunction; + valueType?: FlowNodeItemType['inputs'][number]['valueType']; }) => { const currentNode = getNodeById(nodeId); if (!currentNode) return []; + const isToolNode = edges.some( + (edge) => edge.target === nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools + ); + const nodeVariables = currentNode.inputs - .filter((input) => input.canEdit) + .filter((input) => input.canEdit && (isToolNode || !isToolParamInput(input))) .map((item) => ({ key: item.key, label: item.label ?? item.key, + ...(item.valueType ? { valueType: item.valueType } : {}), parent: { id: currentNode.nodeId, label: currentNode.name, @@ -164,32 +176,66 @@ export const getEditorVariables = ({ t }); - const sourceNodeVariables = !sourceNodes - ? [] - : sourceNodes - .map((node) => { - return filterSelectableWorkflowNodeOutputs({ - outputs: node.outputs, - catchError: node.catchError - }) - .filter((output) => !!output.label) - .map((output) => { - return { - label: - node.nodeId === VARIABLE_NODE_ID && - !workflowSystemVariables.some((item) => item.key === output.id) - ? (output.label ?? output.id) - : t((output.label as any) || ''), - key: output.id, - parent: { - id: node.nodeId, - label: node.name, - avatar: node.avatar - } - }; - }); - }) - .flat(); - - return [...nodeVariables, ...sourceNodeVariables]; + const formatVariable = ( + node: FlowNodeItemType, + output: FlowNodeItemType['outputs'][number], + invalidReason?: EditorVariableLabelPickerType['invalidReason'] + ) => ({ + label: + node.nodeId === VARIABLE_NODE_ID && + !workflowSystemVariables.some((item) => item.key === output.id) + ? (output.label ?? output.id) + : t((output.label as any) || ''), + key: output.id, + ...(output.valueType ? { valueType: output.valueType } : {}), + ...(invalidReason ? { invalidReason } : {}), + parent: { + id: node.nodeId, + label: node.name, + avatar: node.avatar + } + }); + + const getOutputInvalidReason = ({ + node, + output + }: { + node: FlowNodeItemType; + output: FlowNodeItemType['outputs'][number]; + }): EditorVariableLabelPickerType['invalidReason'] => { + const code = getWorkflowReferenceStatus({ + value: [node.nodeId, output.id], + valueType, + sourceNodes, + getNodeById, + chatConfig: appDetail.chatConfig + }).code; + + return code === 'valid' || code === 'empty' ? undefined : code; + }; + + const sourceNodeVariables = sourceNodes.flatMap((node) => { + const selectableOutputIds = new Set( + filterSelectableWorkflowNodeOutputs({ + outputs: node.outputs, + catchError: node.catchError + }).map((output) => output.id) + ); + + return node.outputs + .filter((output) => selectableOutputIds.has(output.id) && !!output.label) + .map((output) => formatVariable(node, output, getOutputInvalidReason({ node, output }))); + }); + + // 保留已存在引用的不可达来源和不可用输出,供标签显示具体失效原因。 + const unavailableNodeVariables = (nodeList ?? []).flatMap((node) => { + return node.outputs + .filter((output) => !!output.label) + .flatMap((output) => { + const invalidReason = getOutputInvalidReason({ node, output }); + return invalidReason ? [formatVariable(node, output, invalidReason)] : []; + }); + }); + + return [...nodeVariables, ...sourceNodeVariables, ...unavailableNodeVariables]; }; diff --git a/projects/app/src/web/core/workflow/referenceCheck.ts b/projects/app/src/web/core/workflow/referenceCheck.ts index 0fd0f5de8d53..3675441532ca 100644 --- a/projects/app/src/web/core/workflow/referenceCheck.ts +++ b/projects/app/src/web/core/workflow/referenceCheck.ts @@ -3,10 +3,7 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { - FlowNodeInputTypeEnum, - FlowNodeOutputTypeEnum -} from '@fastgpt/global/core/workflow/node/constant'; +import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { FlowNodeInputItemType, ReferenceItemValueType, @@ -52,9 +49,38 @@ type GetWorkflowReferenceStatusProps = { chatConfig?: AppChatConfigType; }; +const WORKFLOW_TEXT_REFERENCE_REGEXP = /\{\{\$([^$.]+)\.([^$]+)\$\}\}/g; + +/** 递归提取 canonical 与文本引用,覆盖 HTTP 参数、body 和普通输入嵌套值。 */ +export const getWorkflowReferenceItemsFromValue = (value: unknown) => { + const references: ReferenceItemValueType[] = []; + const visited = new WeakSet(); + + const visit = (item: unknown) => { + if (isWorkflowReferenceItem(item)) { + references.push(item); + return; + } + + if (typeof item === 'string') { + for (const match of item.matchAll(WORKFLOW_TEXT_REFERENCE_REGEXP)) { + references.push([match[1], match[2]]); + } + return; + } + + if (!item || typeof item !== 'object' || visited.has(item)) return; + visited.add(item); + Object.values(item).forEach(visit); + }; + + visit(value); + return [...new Map(references.map((reference) => [reference.join('\0'), reference])).values()]; +}; + const isMalformedReferenceValue = (value: unknown) => { if (!isConfiguredReferenceValue(value) || isWorkflowReferenceItem(value)) return false; - if (!Array.isArray(value)) return true; + if (!Array.isArray(value) || !value.some(Array.isArray)) return false; return value.some((item) => !isWorkflowReferenceItem(item)); }; @@ -78,6 +104,11 @@ export const getWorkflowReferenceSource = ({ return { sourceNode, sourceOutput, + sourceIcon: sourceNode + ? 'name' in sourceNode + ? sourceNode.avatar + : sourceNode.icon + : undefined, sourceLabel: sourceNode ? 'name' in sourceNode ? sourceNode.name @@ -157,7 +188,7 @@ export const getWorkflowReferenceStatuses = ({ value, ...props }: GetWorkflowReferenceStatusProps): WorkflowReferenceStatus[] => { - const referenceItems = getWorkflowReferenceItems(value); + const referenceItems = getWorkflowReferenceItemsFromValue(value); const statuses = referenceItems.map((item) => getWorkflowReferenceStatus({ value: item, ...props }) ); @@ -178,13 +209,15 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ nextNodes, previousChatConfig, nextChatConfig, - globalVariableSourceLabel + globalVariableSourceLabel, + nodeIds }: { previousNodes: Node[]; nextNodes: Node[]; previousChatConfig?: AppChatConfigType; nextChatConfig?: AppChatConfigType; globalVariableSourceLabel?: string; + nodeIds?: Iterable; }) => { const buildSourceNodes = ( nodes: Node[], @@ -193,12 +226,14 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ ...nodes.map(({ data }) => ({ nodeId: data.nodeId, sourceLabel: data.name, + ...(data.avatar ? { icon: data.avatar } : {}), outputs: data.outputs, catchError: data.catchError })), { nodeId: VARIABLE_NODE_ID, sourceLabel: globalVariableSourceLabel, + icon: 'core/workflow/template/variable', outputs: getWorkflowGlobalVariables({ chatConfig: chatConfig ?? {} }).map((variable) => ({ id: variable.key, key: variable.key, @@ -212,6 +247,35 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ const previousSourceNodes = buildSourceNodes(previousNodes, previousChatConfig); const nextSourceNodes = buildSourceNodes(nextNodes, nextChatConfig); + const getReferenceKey = (reference: ReferenceItemValueType) => reference.join('\0'); + const previousSourceKeys = new Set( + previousSourceNodes.flatMap((node) => + node.outputs.map((output) => getReferenceKey([node.nodeId, output.id])) + ) + ); + const nextSourceKeys = new Set( + nextSourceNodes.flatMap((node) => + node.outputs.map((output) => getReferenceKey([node.nodeId, output.id])) + ) + ); + const changedSourceKeys = new Set([...previousSourceKeys, ...nextSourceKeys]); + previousSourceKeys.forEach((key) => { + if (nextSourceKeys.has(key)) changedSourceKeys.delete(key); + }); + nextSourceKeys.forEach((key) => { + if (previousSourceKeys.has(key)) changedSourceKeys.delete(key); + }); + + const affectedNodeIds = nodeIds && new Set(nodeIds); + if (affectedNodeIds) { + nextNodes.forEach((node) => { + const hasChangedReference = getWorkflowReferenceItemsFromValue(node.data.inputs).some( + (reference) => changedSourceKeys.has(getReferenceKey(reference)) + ); + if (hasChangedReference) affectedNodeIds.add(node.data.nodeId); + }); + } + const updateOptional = (item: any, key: string, value: any) => { if (isEqual(item[key], value)) return item; @@ -254,12 +318,14 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ const source = previousSource.sourceOutput ? { sourceLabel: previousSource.sourceLabel, - outputLabel: previousSource.sourceOutput.label + outputLabel: previousSource.sourceOutput.label, + ...(previousSource.sourceIcon ? { icon: previousSource.sourceIcon } : {}) } : existingSnapshot ? { sourceLabel: existingSnapshot.sourceLabel, - outputLabel: existingSnapshot.outputLabel + outputLabel: existingSnapshot.outputLabel, + ...(existingSnapshot.icon ? { icon: existingSnapshot.icon } : {}) } : undefined; @@ -267,7 +333,7 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ }; const captureSnapshots = (value: unknown, existingSnapshots?: WorkflowReferenceSnapshot[]) => { - const snapshots = getWorkflowReferenceItems(value) + const snapshots = getWorkflowReferenceItemsFromValue(value) .map((reference) => captureSnapshot(reference, getExistingSnapshot(reference, existingSnapshots)) ) @@ -277,13 +343,20 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ }; const captureInput = (input: FlowNodeInputItemType): FlowNodeInputItemType => { - let nextInput: FlowNodeInputItemType = nodeInputIsReference(input) - ? updateOptional( - input, - 'referenceSnapshots', - captureSnapshots(input.value, input.referenceSnapshots) - ) - : updateOptional(input, 'referenceSnapshots', undefined); + const hasNestedReferenceSnapshots = [ + NodeInputKeyEnum.ifElseList, + NodeInputKeyEnum.updateList + ].includes(input.key as NodeInputKeyEnum); + const canCaptureReferenceSnapshots = + !hasNestedReferenceSnapshots && + (nodeInputIsReference(input) || getWorkflowReferenceItemsFromValue(input.value).length > 0); + let nextInput: FlowNodeInputItemType = updateOptional( + input, + 'referenceSnapshots', + canCaptureReferenceSnapshots + ? captureSnapshots(input.value, input.referenceSnapshots) + : undefined + ); if (input.key === NodeInputKeyEnum.ifElseList && Array.isArray(input.value)) { const nextValue = (input.value as IfElseListItemType[]).map((branch) => { @@ -325,9 +398,7 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ nextItem = updateOptional( nextItem, 'valueReferenceSnapshots', - item.renderType === FlowNodeInputTypeEnum.reference - ? captureSnapshots(item.value, item.valueReferenceSnapshots) - : undefined + captureSnapshots(item.value, item.valueReferenceSnapshots) ); return nextItem; }); @@ -342,6 +413,7 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ let changed = false; const result = nextNodes.map((node) => { + if (affectedNodeIds && !affectedNodeIds.has(node.data.nodeId)) return node; const inputs = node.data.inputs.map(captureInput); if (inputs.every((input, index) => input === node.data.inputs[index])) return node; diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 8425c7d3d900..7beaed64a8bd 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -434,6 +434,7 @@ export const filterWorkflowNodeOutputsByType = ( export type WorkflowReferenceSourceNode = { nodeId: string; sourceLabel?: string; + icon?: string; outputs: FlowNodeOutputItemType[]; catchError?: boolean; }; diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index 957f7859ce5a..159683137e11 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -11,6 +11,7 @@ import { } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum, + NodeOutputKeyEnum, VARIABLE_NODE_ID, VariableInputEnum, WorkflowIOValueTypeEnum @@ -20,7 +21,12 @@ import type { AppDetailType } from '@fastgpt/global/core/app/type'; import { CanonicalWorkflowDataSchema } from '@fastgpt/global/core/workflow/migration/schema'; import { PublishAppBodySchema } from '@fastgpt/global/openapi/core/app/version/api'; import { storeNode2FlowNode } from '@/web/core/workflow/utils'; -import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; +import { + captureDeletedWorkflowReferenceSnapshots, + getWorkflowReferenceItemsFromValue, + getWorkflowReferenceStatuses +} from '@/web/core/workflow/referenceCheck'; +import { checkWorkflowNodeIssues } from '@/web/core/workflow/workflowCheck'; const createReferenceInput = (key: string, value: unknown, referenceSnapshots?: unknown) => ({ key, @@ -36,29 +42,36 @@ const createNode = ({ nodeId, name, inputs = [], - outputs = [] + outputs = [], + flowNodeType = FlowNodeTypeEnum.userInput, + avatar }: { nodeId: string; name: string; inputs?: any[]; outputs?: any[]; + flowNodeType?: FlowNodeTypeEnum; + avatar?: string; }) => ({ id: nodeId, - type: FlowNodeTypeEnum.userInput, + type: flowNodeType, data: { nodeId, name, + ...(avatar ? { avatar } : {}), + flowNodeType, inputs, outputs } }) as any; -const createOutput = (id: string, label: string) => ({ +const createOutput = (id: string, label: string, extra: Record = {}) => ({ id, key: id, label, - type: FlowNodeOutputTypeEnum.static + type: FlowNodeOutputTypeEnum.static, + ...extra }); describe('WorkflowComponents utils', () => { @@ -955,6 +968,298 @@ describe('WorkflowComponents utils', () => { expect(result.find((item) => item.key === 'name')?.label).toBe('name'); expect(result.find((item) => item.key === 'userId')?.label).toBe('用户 ID'); }); + + it('should keep invalid reason and node icon for unavailable output variables', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + avatar: 'source-avatar', + outputs: [ + createOutput('type-mismatch', 'Type mismatch', { + valueType: WorkflowIOValueTypeEnum.number, + icon: 'custom/type' + }), + createOutput('invalid', 'Invalid', { + invalid: true, + valueType: WorkflowIOValueTypeEnum.string + }) + ] + }); + const disconnected = createNode({ + nodeId: 'disconnected', + name: 'Disconnected', + outputs: [createOutput('output', 'Output', { valueType: WorkflowIOValueTypeEnum.string })] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [ + { + key: 'input', + label: 'Input', + canEdit: true, + valueType: WorkflowIOValueTypeEnum.string + } + ] + }); + const nodeList = [source.data, disconnected.data, consumer.data]; + const result = getEditorVariables({ + nodeId: 'consumer', + nodeList, + getNodeById: (nodeId: string) => nodeList.find((node) => node.nodeId === nodeId), + edges: [{ source: 'source', target: 'consumer' }], + appDetail: { chatConfig: {} } as AppDetailType, + t: (key: string) => key, + valueType: WorkflowIOValueTypeEnum.string + }); + + expect(result.find((item) => item.key === 'type-mismatch')).toMatchObject({ + invalidReason: 'invalid_reference_type', + parent: { avatar: 'source-avatar' } + }); + expect(result.find((item) => item.key === 'invalid')).toMatchObject({ + invalidReason: 'invalid_reference' + }); + expect( + result.find((item) => item.key === 'output' && item.parent.id === 'disconnected') + ).toMatchObject({ + invalidReason: 'unreachable_reference' + }); + expect(result.find((item) => item.key === 'type-mismatch')).not.toHaveProperty('icon'); + }); + }); + + describe('workflow references', () => { + it('extracts canonical and text references from nested values', () => { + expect( + getWorkflowReferenceItemsFromValue({ + url: '{{$source.output$}}', + params: [{ key: '{{$source.key$}}', value: '{{$source.output$}}' }], + body: '{{$other.body$}}' + }) + ).toEqual([ + ['source', 'output'], + ['source', 'key'], + ['other', 'body'] + ]); + }); + + it('keeps valid text references valid', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + outputs: [createOutput('output', 'Output')] + }); + + expect( + getWorkflowReferenceStatuses({ + value: 'prefix {{$source.output$}}', + sourceNodes: [source.data], + getNodeById: () => undefined + }) + ).toEqual([{ code: 'valid' }]); + }); + + it('captures deleted text references from normal and HTTP inputs', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + avatar: 'node-avatar', + outputs: [createOutput('key', 'Key'), createOutput('output', 'Output', { icon: 'input' })] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [ + { + key: 'text', + label: 'Text', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: 'prefix {{$source.output$}}' + }, + { + key: NodeInputKeyEnum.httpParams, + value: [{ key: '{{$source.key$}}', type: 'string', value: '{{$source.output$}}' }] + }, + { + key: NodeInputKeyEnum.httpJsonBody, + value: 'body {{$source.output$}}' + }, + { + key: 'nestedText', + value: { + prompt: 'nested {{$source.key$}}' + } + } + ] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer], + nextNodes: [consumer], + previousChatConfig: {}, + nextChatConfig: {}, + nodeIds: ['source'] + }); + + expect(result[0].data.inputs[0].referenceSnapshots).toEqual([ + { + reference: ['source', 'output'], + sourceLabel: 'Source', + outputLabel: 'Output', + icon: 'node-avatar' + } + ]); + expect(result[0].data.inputs[1].referenceSnapshots).toEqual([ + { + reference: ['source', 'key'], + sourceLabel: 'Source', + outputLabel: 'Key', + icon: 'node-avatar' + }, + { + reference: ['source', 'output'], + sourceLabel: 'Source', + outputLabel: 'Output', + icon: 'node-avatar' + } + ]); + expect(result[0].data.inputs[2].referenceSnapshots).toEqual([ + { + reference: ['source', 'output'], + sourceLabel: 'Source', + outputLabel: 'Output', + icon: 'node-avatar' + } + ]); + expect(result[0].data.inputs[3].referenceSnapshots).toEqual([ + { + reference: ['source', 'key'], + sourceLabel: 'Source', + outputLabel: 'Key', + icon: 'node-avatar' + } + ]); + }); + + it('only updates nodes affected by a deleted source', () => { + const source = createNode({ + nodeId: 'source', + name: 'Source', + outputs: [createOutput('output', 'Output')] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [{ key: 'text', value: '{{$source.output$}}' }] + }); + const untouched = createNode({ + nodeId: 'untouched', + name: 'Untouched', + inputs: [ + { key: 'text', value: 'plain', referenceSnapshots: [{ reference: ['old', 'out'] }] } + ] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [source, consumer, untouched], + nextNodes: [consumer, untouched], + previousChatConfig: {}, + nextChatConfig: {}, + nodeIds: ['source'] + }); + + expect(result[0].data.inputs[0].referenceSnapshots).toHaveLength(1); + expect(result[1]).toBe(untouched); + }); + + it('skips hidden HTTP tool parameter reference checks', () => { + const toolCall = createNode({ + nodeId: 'tool-call', + name: 'Tool call', + flowNodeType: FlowNodeTypeEnum.toolCall + }); + const httpTool = createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'toolParam', + label: 'Tool parameter', + canEdit: true, + defaultToAgentGenerated: true, + renderTypeList: [FlowNodeInputTypeEnum.input], + selectedType: FlowNodeInputTypeEnum.input, + value: ['deleted', 'output'], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.httpReqUrl, + label: 'URL', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: 'https://example.com' + } + ] + }); + + const issueMap = checkWorkflowNodeIssues({ + nodes: [toolCall, httpTool], + edges: [ + { + source: 'tool-call', + target: 'http-tool', + sourceHandle: 'tool', + targetHandle: NodeOutputKeyEnum.selectedTools + } + ] + }); + + expect(issueMap['http-tool'] ?? []).not.toContainEqual( + expect.objectContaining({ inputKey: 'toolParam', code: 'invalid_reference' }) + ); + }); + + it('marks references to hidden HTTP tool parameters as invalid elsewhere', () => { + const toolCall = createNode({ + nodeId: 'tool-call', + name: 'Tool call', + flowNodeType: FlowNodeTypeEnum.toolCall + }); + const httpTool = createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + outputs: [createOutput('result', 'Result')] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [createReferenceInput('input', ['http-tool', 'toolParam'])] + }); + + const issueMap = checkWorkflowNodeIssues({ + nodes: [toolCall, httpTool, consumer], + edges: [ + { + source: 'tool-call', + target: 'http-tool', + sourceHandle: 'tool', + targetHandle: NodeOutputKeyEnum.selectedTools + }, + { + source: 'http-tool', + target: 'consumer', + sourceHandle: 'result', + targetHandle: 'input' + } + ] + }); + + expect(issueMap.consumer).toContainEqual( + expect.objectContaining({ inputKey: 'input', code: 'invalid_reference' }) + ); + }); }); describe('captureDeletedWorkflowReferenceSnapshots', () => { @@ -1031,7 +1336,8 @@ describe('WorkflowComponents utils', () => { const existingSnapshot = { reference: ['missing', 'output'], sourceLabel: 'Old source', - outputLabel: 'Old output' + outputLabel: 'Old output', + icon: 'old-node-avatar' }; const consumer = createNode({ nodeId: 'consumer', @@ -1063,6 +1369,11 @@ describe('WorkflowComponents utils', () => { ['source', 'variable'] ], renderType: FlowNodeInputTypeEnum.reference + }, + { + variable: ['source', 'variable'], + value: ['', 'prefix {{$source.value$}}'], + renderType: FlowNodeInputTypeEnum.input } ] } @@ -1108,6 +1419,15 @@ describe('WorkflowComponents utils', () => { } ] }); + expect(result[0].data.inputs[2].value[1]).toMatchObject({ + valueReferenceSnapshots: [ + { + reference: ['source', 'value'], + sourceLabel: 'Source', + outputLabel: 'Value' + } + ] + }); }); it('captures a deleted global variable from the previous chat config', () => { @@ -1135,7 +1455,53 @@ describe('WorkflowComponents utils', () => { { reference: [VARIABLE_NODE_ID, 'deleted'], sourceLabel: 'Variable', - outputLabel: 'Deleted variable' + outputLabel: 'Deleted variable', + icon: 'core/workflow/template/variable' + } + ]); + }); + + it('captures deleted global references inside VariableUpdate input values', () => { + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [ + { + key: NodeInputKeyEnum.updateList, + value: [ + { + variable: [VARIABLE_NODE_ID, 'target'], + value: ['', 'prefix {{$VARIABLE_NODE_ID.deleted$}}'], + renderType: FlowNodeInputTypeEnum.input + } + ] + } + ] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [consumer], + nextNodes: [consumer], + previousChatConfig: { + variables: [ + { + key: 'deleted', + label: 'Deleted variable', + type: VariableInputEnum.input, + description: '' + } + ] + }, + nextChatConfig: { variables: [] }, + globalVariableSourceLabel: 'Variable' + }); + + expect(result[0].data.inputs[0].value[0].valueReferenceSnapshots).toEqual([ + { + reference: [VARIABLE_NODE_ID, 'deleted'], + sourceLabel: 'Variable', + outputLabel: 'Deleted variable', + icon: 'core/workflow/template/variable' } ]); }); From ebb83bebd52f843c2e050bbf699027a5cbdc2124 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 2 Sep 2026 16:06:12 +0800 Subject: [PATCH 15/18] fix(workflow): check affected references --- packages/global/core/app/formEdit/utils.ts | 11 ++++++ .../context/workflowActionsContext.tsx | 6 ++-- .../context/workflowInitContext.tsx | 26 +++++++++++++- .../context/workflowUtilsContext.tsx | 12 +++---- .../src/web/core/workflow/workflowCheck.ts | 36 ++++++++++++------- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/global/core/app/formEdit/utils.ts b/packages/global/core/app/formEdit/utils.ts index fb8b9fde180e..4a0784694121 100644 --- a/packages/global/core/app/formEdit/utils.ts +++ b/packages/global/core/app/formEdit/utils.ts @@ -132,6 +132,17 @@ export const canInputBeAgentGenerated = ( return !input.renderTypeList.some((type) => agentGeneratedDenyRenderTypes.has(type)); }; +/** 判断输入是否为工作流工具参数。 */ +export const isToolParamInput = ( + input: Pick< + FlowNodeInputItemType, + 'key' | 'canEdit' | 'defaultToAgentGenerated' | 'renderTypeList' | 'canAgentGenerated' + > +) => + input.canEdit === true && + input.defaultToAgentGenerated === true && + canInputBeAgentGenerated(input); + /** * 归一当前节点输入的可选来源和默认选择。 * 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 f74e676c3560..7b2312b2bd0e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -441,7 +441,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod nextNodes, previousChatConfig: appDetail.chatConfig, nextChatConfig: appDetail.chatConfig, - globalVariableSourceLabel: t('common:core.module.Variable') + globalVariableSourceLabel: t('common:core.module.Variable'), + nodeIds: [id] }); }); @@ -606,7 +607,8 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod nextNodes, previousChatConfig: appDetail.chatConfig, nextChatConfig: appDetail.chatConfig, - globalVariableSourceLabel: t('common:core.module.Variable') + globalVariableSourceLabel: t('common:core.module.Variable'), + nodeIds: nodeIdsToRecheck }); }); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx index 5b46cf469393..664a62da0cb5 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx @@ -28,6 +28,7 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { getWebLLMModel } from '@/web/common/system/utils'; import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import { AppContext } from '@/pageComponents/app/detail/context'; +import { useTranslation } from 'next-i18next'; type OnChange = (changes: ChangesType[]) => void; @@ -129,6 +130,8 @@ const WorkflowInitContextProvider = ({ // Nodes const [nodes = [], setNodes] = useNodesState([]); const chatConfig = useContextSelector(AppContext, (v) => v.appDetail.chatConfig); + const { t } = useTranslation(); + const previousChatConfigRef = useRef(chatConfig); const onNodesChange = useCallback( (changes: NodeChange[]) => { setNodes((state) => { @@ -137,12 +140,33 @@ const WorkflowInitContextProvider = ({ previousNodes: state, nextNodes, previousChatConfig: chatConfig, - nextChatConfig: chatConfig + nextChatConfig: chatConfig, + nodeIds: changes.flatMap((change) => { + if (change.type === 'add') return [change.item.id]; + return 'id' in change ? [change.id] : []; + }) }); }); }, [chatConfig, setNodes] ); + + useDeepCompareEffect(() => { + const previousChatConfig = previousChatConfigRef.current; + previousChatConfigRef.current = chatConfig; + if (previousChatConfig === chatConfig) return; + + setNodes((state) => + captureDeletedWorkflowReferenceSnapshots({ + previousNodes: state, + nextNodes: state, + previousChatConfig, + nextChatConfig: chatConfig, + globalVariableSourceLabel: t('common:core.module.Variable'), + nodeIds: [] + }) + ); + }, [chatConfig, setNodes, t]); const getNodes = useMemoizedFn(() => nodes); const nodeFormat = useMemo(() => { 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 2837b894c41b..b6dc0d0f6e74 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -31,7 +31,7 @@ import { AppContext } from '../../context'; import { WorkflowSnapshotContext } from './workflowSnapshotContext'; import { WorkflowActionsContext } from './workflowActionsContext'; import { - canInputBeAgentGenerated, + isToolParamInput, normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; import { isEqual } from 'lodash-es'; @@ -81,12 +81,7 @@ export const splitToolInputsByMode = (inputs: FlowNodeInputItemType[], isTool: b inputs.forEach((item) => { const normalizedInput = normalizeFlowNodeInputType(item, { isTool }); // canEdit 仅表示该字段可在节点内编辑;代码变量不应自动成为工具参数。 - const isToolParamInput = - item.canEdit === true && - item.defaultToAgentGenerated === true && - canInputBeAgentGenerated(item); - - if (isTool && isToolParamInput) { + if (isTool && isToolParamInput(item)) { toolInputs.push(item); return; } @@ -161,7 +156,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => nextNodes: nodes, previousChatConfig, nextChatConfig, - globalVariableSourceLabel: t('common:core.module.Variable') + globalVariableSourceLabel: t('common:core.module.Variable'), + nodeIds: [] }) ); }, [appDetail.chatConfig, setNodes, t]); diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index 6a826afccc0e..544865fc78ed 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -30,7 +30,8 @@ import { canInputBeAgentGenerated, initToolInputTypeByDefaultMode, isToolInputValueConfigured, - isAgentGeneratedToolInput + isAgentGeneratedToolInput, + isToolParamInput } from '@fastgpt/global/core/app/formEdit/utils'; import { isToolNotExistError } from '@fastgpt/global/core/app/utils'; import { @@ -43,6 +44,7 @@ import { } from './utils'; import { getWorkflowReferenceIssueCode as getReferenceIssueCode, + getWorkflowReferenceItemsFromValue, getWorkflowReferenceStatuses } from './referenceCheck'; @@ -901,20 +903,30 @@ export const checkWorkflowNodeIssues = ({ } } + if ( + data.flowNodeType === FlowNodeTypeEnum.httpRequest468 && + isToolNode && + isToolParamInput(input) + ) { + return; + } + const isReferenceInput = nodeInputIsReference(input); // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 const effectiveInputValue = input.value ?? input.defaultValue; - - const referenceIssueCode = isReferenceInput - ? getReferenceIssueCode( - getReferenceStatuses({ - value: effectiveInputValue, - valueType: input.valueType, - sourceNodes: getInputSourceNodes(input), - context - }) - ) - : undefined; + const hasTextReference = getWorkflowReferenceItemsFromValue(effectiveInputValue).length > 0; + + const referenceIssueCode = + isReferenceInput || hasTextReference + ? getReferenceIssueCode( + getReferenceStatuses({ + value: effectiveInputValue, + valueType: input.valueType, + sourceNodes: getInputSourceNodes(input), + context + }) + ) + : undefined; if (referenceIssueCode) { addIssue({ node, From 9a9a1762c383bf7e793c94402370830fbad6e714 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 2 Sep 2026 16:24:28 +0800 Subject: [PATCH 16/18] fix(workflow): handles self references check in http node Signed-off-by: Nixieboluo --- .../context/workflowInitContext.tsx | 31 ++++++ .../src/web/core/workflow/referenceCheck.ts | 40 ++++++-- .../src/web/core/workflow/workflowCheck.ts | 35 ++++--- .../detail/WorkflowComponents/utils.test.ts | 98 +++++++++++++++++++ 4 files changed, 183 insertions(+), 21 deletions(-) diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx index 664a62da0cb5..765663f5b697 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx @@ -32,6 +32,13 @@ import { useTranslation } from 'next-i18next'; type OnChange = (changes: ChangesType[]) => void; +const getToolNodeIds = (edges: Edge[]) => + new Set( + edges + .filter((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools) + .map((edge) => edge.target) + ); + type WorkflowNodeContextType = { nodes: Node[]; rawNodesMap: Record>; @@ -340,6 +347,30 @@ const WorkflowInitContextProvider = ({ // Edges const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const previousToolNodeIdsRef = useRef>(); + + useDeepCompareEffect(() => { + const previousToolNodeIds = previousToolNodeIdsRef.current; + const nextToolNodeIds = getToolNodeIds(edges); + previousToolNodeIdsRef.current = nextToolNodeIds; + if ( + !previousToolNodeIds || + (previousToolNodeIds.size === nextToolNodeIds.size && + [...previousToolNodeIds].every((nodeId) => nextToolNodeIds.has(nodeId))) + ) { + return; + } + + setNodes((state) => + captureDeletedWorkflowReferenceSnapshots({ + previousNodes: state, + nextNodes: state, + previousToolNodeIds, + nextToolNodeIds, + nodeIds: [] + }) + ); + }, [edges, setNodes]); const toolNodesMap = useMemoEnhance(() => { const selectedToolEdgeMap: Record = {}; diff --git a/projects/app/src/web/core/workflow/referenceCheck.ts b/projects/app/src/web/core/workflow/referenceCheck.ts index 3675441532ca..a1e82fbd0c36 100644 --- a/projects/app/src/web/core/workflow/referenceCheck.ts +++ b/projects/app/src/web/core/workflow/referenceCheck.ts @@ -3,7 +3,10 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; import type { FlowNodeInputItemType, ReferenceItemValueType, @@ -16,6 +19,7 @@ import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/ import type { TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type'; import type { Node } from 'reactflow'; import { isEqual } from 'lodash-es'; +import { isToolParamInput } from '@fastgpt/global/core/app/formEdit/utils'; import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; import { filterSelectableWorkflowNodeOutputs, @@ -41,6 +45,18 @@ export type WorkflowReferenceStatus = { export type WorkflowReferenceIssueCode = Exclude; +/** HTTP 作为工具时,工具参数可被该节点的其他输入引用。 */ +export const getHTTPToolParamOutputs = (node: FlowNodeItemType) => + node.flowNodeType === FlowNodeTypeEnum.httpRequest468 + ? node.inputs.filter(isToolParamInput).map((input) => ({ + id: input.key, + key: input.key, + type: FlowNodeOutputTypeEnum.static, + label: input.label ?? input.key, + valueType: input.valueType + })) + : []; + type GetWorkflowReferenceStatusProps = { value: unknown; valueType?: WorkflowIOValueTypeEnum; @@ -210,7 +226,9 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ previousChatConfig, nextChatConfig, globalVariableSourceLabel, - nodeIds + nodeIds, + previousToolNodeIds, + nextToolNodeIds }: { previousNodes: Node[]; nextNodes: Node[]; @@ -218,16 +236,22 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ nextChatConfig?: AppChatConfigType; globalVariableSourceLabel?: string; nodeIds?: Iterable; + previousToolNodeIds?: ReadonlySet; + nextToolNodeIds?: ReadonlySet; }) => { const buildSourceNodes = ( nodes: Node[], - chatConfig: AppChatConfigType | undefined + chatConfig: AppChatConfigType | undefined, + toolNodeIds?: ReadonlySet ): WorkflowReferenceSourceNode[] => [ ...nodes.map(({ data }) => ({ nodeId: data.nodeId, sourceLabel: data.name, ...(data.avatar ? { icon: data.avatar } : {}), - outputs: data.outputs, + outputs: [ + ...data.outputs, + ...(toolNodeIds?.has(data.nodeId) ? getHTTPToolParamOutputs(data) : []) + ], catchError: data.catchError })), { @@ -244,8 +268,12 @@ export const captureDeletedWorkflowReferenceSnapshots = ({ } ]; - const previousSourceNodes = buildSourceNodes(previousNodes, previousChatConfig); - const nextSourceNodes = buildSourceNodes(nextNodes, nextChatConfig); + const previousSourceNodes = buildSourceNodes( + previousNodes, + previousChatConfig, + previousToolNodeIds + ); + const nextSourceNodes = buildSourceNodes(nextNodes, nextChatConfig, nextToolNodeIds); const getReferenceKey = (reference: ReferenceItemValueType) => reference.join('\0'); const previousSourceKeys = new Set( diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index 544865fc78ed..e2f1a5ebfe2d 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -43,6 +43,7 @@ import { workflowValueTypeIsCompatible } from './utils'; import { + getHTTPToolParamOutputs, getWorkflowReferenceIssueCode as getReferenceIssueCode, getWorkflowReferenceItemsFromValue, getWorkflowReferenceStatuses @@ -466,24 +467,28 @@ export const checkWorkflowNodeIssues = ({ edges }) ); - const getInputSourceNodes = (input: FlowNodeInputItemType) => { - if (data.flowNodeType !== FlowNodeTypeEnum.httpRequest468 || input.canEdit !== true) { - return sourceNodes; - } - - return getSourceNodes( - getNodeAllSourceIds({ - nodeId: data.nodeId, - getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, - edges, - includeChildren: true, - childrenNodeIdListMap: context.childrenNodeIdListMap - }) - ); - }; const isToolNode = context.incomingEdgesMap .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); + const getInputSourceNodes = (input: FlowNodeInputItemType) => { + const inputSourceNodes = + data.flowNodeType === FlowNodeTypeEnum.httpRequest468 && input.canEdit === true + ? getSourceNodes( + getNodeAllSourceIds({ + nodeId: data.nodeId, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + edges, + includeChildren: true, + childrenNodeIdListMap: context.childrenNodeIdListMap + }) + ) + : sourceNodes; + const toolParamOutputs = isToolNode ? getHTTPToolParamOutputs(data) : []; + + return toolParamOutputs.length > 0 + ? [...inputSourceNodes, { ...data, outputs: toolParamOutputs }] + : inputSourceNodes; + }; const status = data.status ?? data.pluginData?.status; const isToolOffline = status === PluginStatusEnum.Offline; diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index 159683137e11..f38f6d3a5e6a 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -1173,6 +1173,46 @@ describe('WorkflowComponents utils', () => { expect(result[1]).toBe(untouched); }); + it('captures references to HTTP tool parameters when the node leaves tool mode', () => { + const httpTool = createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + avatar: 'http-avatar', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'toolParam', + label: 'Tool parameter', + canEdit: true, + defaultToAgentGenerated: true, + renderTypeList: [FlowNodeInputTypeEnum.input], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.httpReqUrl, + value: 'https://example.com/{{$http-tool.toolParam$}}' + } + ] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [httpTool], + nextNodes: [httpTool], + previousToolNodeIds: new Set(['http-tool']), + nextToolNodeIds: new Set(), + nodeIds: [] + }); + + expect(result[0].data.inputs[1].referenceSnapshots).toEqual([ + { + reference: ['http-tool', 'toolParam'], + sourceLabel: 'HTTP tool', + outputLabel: 'Tool parameter', + icon: 'http-avatar' + } + ]); + }); + it('skips hidden HTTP tool parameter reference checks', () => { const toolCall = createNode({ nodeId: 'tool-call', @@ -1220,6 +1260,64 @@ describe('WorkflowComponents utils', () => { ); }); + it('keeps HTTP tool parameter references valid only while connected as a tool', () => { + const toolCall = createNode({ + nodeId: 'tool-call', + name: 'Tool call', + flowNodeType: FlowNodeTypeEnum.toolCall + }); + const httpTool = createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'toolParam', + label: 'Tool parameter', + canEdit: true, + defaultToAgentGenerated: true, + renderTypeList: [FlowNodeInputTypeEnum.input], + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.httpReqUrl, + label: 'URL', + renderTypeList: [FlowNodeInputTypeEnum.input], + value: 'https://example.com/{{$http-tool.toolParam$}}' + } + ] + }); + const toolEdge = { + source: 'tool-call', + target: 'http-tool', + sourceHandle: 'tool', + targetHandle: NodeOutputKeyEnum.selectedTools + }; + + expect( + checkWorkflowNodeIssues({ + nodes: [toolCall, httpTool], + edges: [toolEdge] + })['http-tool'] ?? [] + ).not.toContainEqual( + expect.objectContaining({ + inputKey: NodeInputKeyEnum.httpReqUrl, + code: 'invalid_reference' + }) + ); + expect( + checkWorkflowNodeIssues({ + nodes: [toolCall, httpTool], + edges: [] + })['http-tool'] + ).toContainEqual( + expect.objectContaining({ + inputKey: NodeInputKeyEnum.httpReqUrl, + code: 'invalid_reference' + }) + ); + }); + it('marks references to hidden HTTP tool parameters as invalid elsewhere', () => { const toolCall = createNode({ nodeId: 'tool-call', From 0b90fc60fd6bc38ac80c3334607a18b513628c4d Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 2 Sep 2026 17:34:40 +0800 Subject: [PATCH 17/18] fix(workflow): align reference checks --- .../common/Icon/icons/common/warn.svg | 2 +- .../common/MySelect/MultipleRowSelect.tsx | 8 ++ .../web/components/common/MySelect/type.ts | 1 + .../app/detail/Plugin/Header.tsx | 1 + .../app/detail/Workflow/Header.tsx | 1 + .../RenderInput/templates/Reference.tsx | 40 ++++-- .../context/workflowActionsContext.tsx | 100 +++++++-------- .../context/workflowUtilsContext.tsx | 73 ++++------- .../src/web/core/workflow/workflowCheck.ts | 67 +++++----- .../test/web/core/app/workflow/utils.test.ts | 114 +++++++++++++++++- 10 files changed, 255 insertions(+), 152 deletions(-) diff --git a/packages/web/components/common/Icon/icons/common/warn.svg b/packages/web/components/common/Icon/icons/common/warn.svg index 91824fcad0ed..a8c912e2c2bf 100644 --- a/packages/web/components/common/Icon/icons/common/warn.svg +++ b/packages/web/components/common/Icon/icons/common/warn.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/packages/web/components/common/MySelect/MultipleRowSelect.tsx b/packages/web/components/common/MySelect/MultipleRowSelect.tsx index a2b3b2ae2df6..c6d011844453 100644 --- a/packages/web/components/common/MySelect/MultipleRowSelect.tsx +++ b/packages/web/components/common/MySelect/MultipleRowSelect.tsx @@ -155,6 +155,7 @@ const RenderList = React.memo(function RenderList({ export const MultipleRowSelect = ({ placeholder, label, + rightContent, value = [], list, emptyTip, @@ -194,6 +195,7 @@ export const MultipleRowSelect = ({ return ( {label ?? placeholder} @@ -285,6 +288,11 @@ export const MultipleRowSelect = ({ /> + {rightContent && ( + + {rightContent} + + )} ); }; diff --git a/packages/web/components/common/MySelect/type.ts b/packages/web/components/common/MySelect/type.ts index 8a9c03867eea..554358fe3a42 100644 --- a/packages/web/components/common/MySelect/type.ts +++ b/packages/web/components/common/MySelect/type.ts @@ -10,6 +10,7 @@ export type MultipleSelectProps = { label?: string | React.ReactNode; value?: any[]; placeholder?: string; + rightContent?: React.ReactNode; list: ListItemType[]; emptyTip?: string; maxH?: number; diff --git a/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx b/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx index 8c58d0912d95..0ce5dc220cf7 100644 --- a/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx @@ -245,6 +245,7 @@ const Header = () => { isOpen={isOpenBackConfirm} onClose={onCloseBackConfirm} iconSrc="common/warn" + color={'#F79009'} title={t('common:Exit')} w={'400px'} > diff --git a/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx b/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx index 83d60f12bc29..8cbc86943355 100644 --- a/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx @@ -245,6 +245,7 @@ const Header = () => { isOpen={isOpenBackConfirm} onClose={onCloseBackConfirm} iconSrc="common/warn" + color={'#F79009'} title={t('common:Exit')} w={'400px'} > diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 9f6cd3d97a7d..46ea02fd1bd5 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import type { RenderInputProps } from '../type'; import { Flex, Box, type ButtonProps, Grid } from '@chakra-ui/react'; +import { WarningTwoIcon } from '@chakra-ui/icons'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { getNodeAllSource, @@ -404,8 +405,15 @@ const SingleReferenceSelector = ({ /> ) : isInvalidReference ? ( - - + + ) } + rightContent={ + isInvalidReference ? ( + + event.stopPropagation()} + > + + + + ) : undefined + } value={selectorVal} list={list} onSelect={(nextValue) => { @@ -430,7 +455,6 @@ const SingleReferenceSelector = ({ ? { ...ButtonProps, borderColor: 'red.500', - color: 'red.500', _hover: { borderColor: 'red.400' } } : ButtonProps @@ -438,13 +462,7 @@ const SingleReferenceSelector = ({ /> ); - return isInvalidReference ? ( - - {selector} - - ) : ( - selector - ); + return selector; }; const MultipleReferenceSelector = ({ placeholder, @@ -527,7 +545,7 @@ const MultipleReferenceSelector = ({ w={'100%'} alignItems={'center'} bg={isInvalidReference ? 'red.50' : 'primary.50'} - color={isInvalidReference ? 'red.500' : 'myGray.900'} + color={'myGray.900'} py={1} px={1.5} rounded={'sm'} 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 7b2312b2bd0e..d781f8b4f7df 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -3,7 +3,7 @@ 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'; -import { getHandleId, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; +import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { migrateToolInputConfig } from '@fastgpt/global/core/app/formEdit/utils'; import type { OnConnectStartParams } from 'reactflow'; @@ -17,10 +17,7 @@ import type { WorkflowCheckNodeIssueMap } from '@fastgpt/global/core/workflow/type/node'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { - checkWorkflowNodeIssues, - countNewWorkflowCheckIssues -} from '@/web/core/workflow/workflowCheck'; +import { checkWorkflowNodeIssues } from '@/web/core/workflow/workflowCheck'; import { collectWorkflowStartAutoFillRevertPatches } from '@/web/core/workflow/workflowStartAutoFill'; import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; @@ -83,6 +80,9 @@ type WorkflowActionsContextValue = { /** 单节点刷新校验问题详情,用于节点配置编辑后的局部复查 */ onRefreshSingleNodeWorkflowCheckIssues: (nodeId: string) => void; + /** 防抖全量刷新节点校验问题详情,用于影响多个节点的配置变更 */ + onRefreshWorkflowCheckIssues: (options?: { showReferenceIssueToast?: boolean }) => void; + /** 移除所有错误状态 */ onRemoveError: () => void; @@ -116,6 +116,9 @@ export const WorkflowActionsContext = createContext void nodeId; throw new Error('Function not implemented.'); }, + onRefreshWorkflowCheckIssues: () => { + throw new Error('Function not implemented.'); + }, onRemoveError: () => { throw new Error('Function not implemented.'); }, @@ -156,6 +159,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod const singleNodeCheckTimerRef = useRef>>(new Map()); const edgeCheckTimerRef = useRef | null>(null); + const showReferenceIssueToastRef = useRef(false); const isFirstEdgesEffectRef = useRef(true); const prevEdgesRef = useRef(edges); @@ -297,40 +301,48 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod [onRefreshSingleNodeWorkflowCheckIssues] ); - /** 结构变化后防抖全量扫描,及时更新依赖节点关系和输出的引用错误态。 */ - const scheduleFullWorkflowCheck = useCallback(() => { - if (edgeCheckTimerRef.current) { - clearTimeout(edgeCheckTimerRef.current); - } + /** 结构变化后防抖全量扫描,更新受影响节点的校验问题。 */ + const scheduleFullWorkflowCheck = useCallback( + (showReferenceIssueToast = false) => { + showReferenceIssueToastRef.current ||= showReferenceIssueToast; - edgeCheckTimerRef.current = setTimeout(() => { - edgeCheckTimerRef.current = null; - const nodes = getNodes(); - if (nodes.length === 0) return; - - const issueMap = checkWorkflowNodeIssues({ - nodes, - edges, - t, - chatConfig: appDetail.chatConfig - }); + if (edgeCheckTimerRef.current) { + clearTimeout(edgeCheckTimerRef.current); + } - // 输出/catchError 变化产生新增类型不兼容时聚合提示一次,已有问题不重复提醒 - const newTypeIssueCount = countNewWorkflowCheckIssues({ - issueMap, - prevNodes: nodes, - code: 'invalid_reference_type' - }); - if (newTypeIssueCount > 0) { - toast({ - status: 'warning', - title: t('common:core.workflow.check.invalid_reference_type_toast') + edgeCheckTimerRef.current = setTimeout(() => { + edgeCheckTimerRef.current = null; + const shouldShowReferenceIssueToast = showReferenceIssueToastRef.current; + showReferenceIssueToastRef.current = false; + const nodes = getNodes(); + if (nodes.length === 0) return; + + const issueMap = checkWorkflowNodeIssues({ + nodes, + edges, + t, + chatConfig: appDetail.chatConfig }); - } - onSyncWorkflowCheckIssues(issueMap); - }, 400); - }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); + onSyncWorkflowCheckIssues(issueMap); + + if (shouldShowReferenceIssueToast) { + toast({ + status: 'warning', + title: t('common:core.workflow.check.invalid_reference_type_toast') + }); + } + }, 400); + }, + [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast] + ); + + const onRefreshWorkflowCheckIssues = useCallback( + (options?: { showReferenceIssueToast?: boolean }) => { + scheduleFullWorkflowCheck(options?.showReferenceIssueToast); + }, + [scheduleFullWorkflowCheck] + ); useEffect(() => { if (isFirstEdgesEffectRef.current) { @@ -612,22 +624,13 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }); }); - // 变更影响「其他节点」上的引用有效性,单节点复查覆盖不到,需要升级为全量检查: - // - catchError 决定该节点的 error 输出是否可被引用; - // - 输出的增删改直接影响下游已选引用(addOutput 本身不会让旧引用失效, - // 但会触发 invalidCondition 重算,可能把兄弟输出置为 invalid); - // - aiModel 变化会影响 aiChat 的 reasoningText 输出是否可被引用; - // - delOutput/replaceOutput 还会删边,与 edges effect 的全量检查重复, - // 但两者共用同一个防抖 timer,最终只会扫描一次。 + // 结构或输出变化会影响多个节点,需升级为全量检查。 const shouldRunFullWorkflowCheck = updateData.some((item) => { if (item.type === 'attr') { return ['catchError', 'parentNodeId'].includes(item.key); } if (item.type === 'addInput') { - return ( - item.value.key === NodeInputKeyEnum.childrenNodeIdList || - nodeInputIsReference(item.value) - ); + return item.value.key === NodeInputKeyEnum.childrenNodeIdList; } if (item.type === 'delInput') { return item.key === NodeInputKeyEnum.childrenNodeIdList; @@ -635,8 +638,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod if (item.type === 'updateInput' || item.type === 'replaceInput') { return ( item.key === NodeInputKeyEnum.aiModel || - item.key === NodeInputKeyEnum.childrenNodeIdList || - nodeInputIsReference(item.value) + item.key === NodeInputKeyEnum.childrenNodeIdList ); } return ['updateOutput', 'replaceOutput', 'addOutput', 'delOutput'].includes(item.type); @@ -666,6 +668,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod onUpdateNodeError, onSyncWorkflowCheckIssues, onRefreshSingleNodeWorkflowCheckIssues, + onRefreshWorkflowCheckIssues, onRemoveError, onResetNode, onChangeNode, @@ -677,6 +680,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod onUpdateNodeError, onSyncWorkflowCheckIssues, onRefreshSingleNodeWorkflowCheckIssues, + onRefreshWorkflowCheckIssues, onRemoveError, onResetNode, onChangeNode, 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 b6dc0d0f6e74..b332a747d10d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -6,11 +6,7 @@ import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import { storeNode2FlowNode, storeEdge2RenderEdge } from '@/web/core/workflow/utils'; -import { - checkWorkflowBeforeRunOrPublish, - checkWorkflowNodeIssues, - countNewWorkflowCheckIssues -} from '@/web/core/workflow/workflowCheck'; +import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck'; import { uiWorkflow2StoreWorkflow } from '../utils'; import { FlowNodeOutputTypeEnum, @@ -124,8 +120,6 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => const { toast } = useToast(); const { fitView } = useReactFlow(); - // 首次进入编辑页的全量扫描不对存量类型问题提示;之后的检查才聚合提示新增类型问题 - const hasRunScheduledCheckRef = useRef(false); const { feConfigs } = useSystemStore(); const { teamPlanStatus } = useUserStore(); const showSandbox = feConfigs?.show_agent_sandbox; @@ -138,10 +132,12 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => (v) => v ); const { past, setPast } = useContextSelector(WorkflowSnapshotContext, (v) => v); - const { onRemoveError, onUpdateNodeError, onSyncWorkflowCheckIssues } = useContextSelector( - WorkflowActionsContext, - (v) => v - ); + const { + onRemoveError, + onUpdateNodeError, + onSyncWorkflowCheckIssues, + onRefreshWorkflowCheckIssues + } = useContextSelector(WorkflowActionsContext, (v) => v); useEffect(() => { const previousChatConfig = previousChatConfigRef.current; @@ -150,6 +146,18 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => if (isEqual(previousChatConfig?.variables, nextChatConfig?.variables)) return; + const nextVariablesByKey = new Map( + (nextChatConfig?.variables ?? []).map((variable) => [variable.key, variable]) + ); + const showReferenceIssueToast = (previousChatConfig?.variables ?? []).some((variable) => { + const nextVariable = nextVariablesByKey.get(variable.key); + return ( + !nextVariable || + nextVariable.type !== variable.type || + nextVariable.valueType !== variable.valueType + ); + }); + setNodes((nodes) => captureDeletedWorkflowReferenceSnapshots({ previousNodes: nodes, @@ -160,7 +168,8 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => nodeIds: [] }) ); - }, [appDetail.chatConfig, setNodes, t]); + onRefreshWorkflowCheckIssues({ showReferenceIssueToast }); + }, [appDetail.chatConfig, onRefreshWorkflowCheckIssues, setNodes, t]); // 优化为单次遍历,分类输出项 const splitOutput = useCallback((outputs: FlowNodeOutputItemType[]) => { @@ -297,46 +306,6 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) => ] ); - /** 编辑页定时全量扫描,主动发现新增/已修复的节点错误。 */ - useEffect(() => { - const runScheduledCheck = () => { - const nodes = getNodes(); - if (nodes.length === 0) return; - - const issueMap = checkWorkflowNodeIssues({ - nodes, - edges, - t, - chatConfig: appDetail.chatConfig - }); - - // 变量配置变化会立即重跑本检查;新增类型不兼容时聚合提示一次 - if (hasRunScheduledCheckRef.current) { - const newTypeIssueCount = countNewWorkflowCheckIssues({ - issueMap, - prevNodes: nodes, - code: 'invalid_reference_type' - }); - if (newTypeIssueCount > 0) { - toast({ - status: 'warning', - title: t('common:core.workflow.check.invalid_reference_type_toast') - }); - } - } - hasRunScheduledCheckRef.current = true; - - onSyncWorkflowCheckIssues(issueMap); - }; - - const timer = window.setInterval(runScheduledCheck, 10_000); - runScheduledCheck(); - - return () => { - window.clearInterval(timer); - }; - }, [appDetail.chatConfig, edges, getNodes, onSyncWorkflowCheckIssues, t, toast]); - // 4. initData - 初始化工作流数据 const initData = useCallback( async ( diff --git a/projects/app/src/web/core/workflow/workflowCheck.ts b/projects/app/src/web/core/workflow/workflowCheck.ts index e2f1a5ebfe2d..de67f8cb1405 100644 --- a/projects/app/src/web/core/workflow/workflowCheck.ts +++ b/projects/app/src/web/core/workflow/workflowCheck.ts @@ -6,7 +6,8 @@ import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import type { Edge, Node } from 'reactflow'; import { FlowNodeInputTypeEnum, - FlowNodeTypeEnum + FlowNodeTypeEnum, + isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum, @@ -471,18 +472,22 @@ export const checkWorkflowNodeIssues = ({ .get(data.nodeId) ?.some((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools); const getInputSourceNodes = (input: FlowNodeInputItemType) => { - const inputSourceNodes = - data.flowNodeType === FlowNodeTypeEnum.httpRequest468 && input.canEdit === true - ? getSourceNodes( - getNodeAllSourceIds({ - nodeId: data.nodeId, - getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, - edges, - includeChildren: true, - childrenNodeIdListMap: context.childrenNodeIdListMap - }) - ) - : sourceNodes; + // 容器输出选择器允许引用直系子节点,校验需沿用同一范围。 + const includeChildren = + input.canEdit === true && + (data.flowNodeType === FlowNodeTypeEnum.httpRequest468 || + isNestedParentNodeType(data.flowNodeType)); + const inputSourceNodes = includeChildren + ? getSourceNodes( + getNodeAllSourceIds({ + nodeId: data.nodeId, + getNodeById: (sourceNodeId) => context.nodeMap.get(sourceNodeId ?? '')?.data, + edges, + includeChildren: true, + childrenNodeIdListMap: context.childrenNodeIdListMap + }) + ) + : sourceNodes; const toolParamOutputs = isToolNode ? getHTTPToolParamOutputs(data) : []; return toolParamOutputs.length > 0 @@ -569,7 +574,14 @@ export const checkWorkflowNodeIssues = ({ node, code, message: getWorkflowCheckIssueMessage(code, t, { - inputName: field === 'variable' ? '变量' : '值' + inputName: + field === 'variable' + ? t + ? t('common:core.workflow.variable' as any) + : '变量' + : t + ? t('common:value' as any) + : '值' }), inputKey: `${NodeInputKeyEnum.ifElseList}[${branchIndex}].list[${conditionIndex}].${field}` }); @@ -919,7 +931,10 @@ export const checkWorkflowNodeIssues = ({ const isReferenceInput = nodeInputIsReference(input); // 节点未显式配置时,runtime 会回退 defaultValue;运行检查应与实际执行一致。 const effectiveInputValue = input.value ?? input.defaultValue; - const hasTextReference = getWorkflowReferenceItemsFromValue(effectiveInputValue).length > 0; + // 容器子节点 ID 列表可能恰好形如 [nodeId, nodeId],不能当成引用元组。 + const hasTextReference = + input.key !== NodeInputKeyEnum.childrenNodeIdList && + getWorkflowReferenceItemsFromValue(effectiveInputValue).length > 0; const referenceIssueCode = isReferenceInput || hasTextReference @@ -1025,28 +1040,6 @@ export const checkWorkflowHasError = (nodeIssueMap: WorkflowCheckNodeIssueMap) = * 对比节点上一次检查结果,统计指定 code 的新增问题数量。 * 用于类型等配置变化后聚合提示一次,不对已存在的问题重复提醒。 */ -export const countNewWorkflowCheckIssues = ({ - issueMap, - prevNodes, - code -}: { - issueMap: WorkflowCheckNodeIssueMap; - prevNodes: Array<{ data: FlowNodeItemType }>; - code: string; -}) => { - let count = 0; - for (const [nodeId, issues] of Object.entries(issueMap)) { - const prevIssues = prevNodes.find((node) => node.data.nodeId === nodeId)?.data - .workflowCheckIssues; - count += issues.filter( - (issue) => - issue.code === code && - !prevIssues?.some((prev) => prev.code === issue.code && prev.inputKey === issue.inputKey) - ).length; - } - return count; -}; - /** 返回存在 error 的 nodeId 列表;传入 nodeOrder 时按画布节点顺序排列,便于稳定定位第一个错误节点。 */ export const getWorkflowCheckErrorNodeIds = ( nodeIssueMap: WorkflowCheckNodeIssueMap, 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 6f8a442b3e98..8674b12b3750 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -743,6 +743,103 @@ describe('checkWorkflowNodeIssues', () => { ); }); + it('allows a loop custom output to reference a direct child output', () => { + const loop = makeNode('loop', FlowNodeTypeEnum.loopRun, { + inputs: [ + { + ...makeReferenceInput('custom-output', ['loop-child', 'out']), + canEdit: true + } + ] + }); + const child = makeOutputNode('loop-child'); + child.data.parentNodeId = 'loop'; + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, loop, child], + edges: [{ id: 'e-start-loop', source: 'start', target: 'loop', type: EDGE_TYPE }] + }); + + expect(result.loop?.filter((issue) => issue.inputKey === 'custom-output') ?? []).toEqual([]); + }); + + it('keeps nested loop and parallel container reference scopes aligned', () => { + const source = makeNode('source', FlowNodeTypeEnum.answerNode, { + outputs: [ + { + id: 'items', + key: 'items', + label: 'items', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.arrayAny + } + ] + }); + const outerLoop = makeNode('outer-loop', FlowNodeTypeEnum.loopRun); + const parallel = makeNode('parallel', FlowNodeTypeEnum.parallelRun, { + parentNodeId: 'outer-loop', + inputs: [ + { + ...makeReferenceInput('nested-input', ['source', 'items']), + valueType: WorkflowIOValueTypeEnum.arrayAny + } + ] + }); + const nestedLoop = makeNode('nested-loop', FlowNodeTypeEnum.loopRun, { + parentNodeId: 'parallel', + inputs: [ + { + ...makeReferenceInput('custom-output', ['nested-child', 'out']), + canEdit: true + } + ] + }); + const nestedChild = makeOutputNode('nested-child'); + nestedChild.data.parentNodeId = 'nested-loop'; + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, source, outerLoop, parallel, nestedLoop, nestedChild], + edges: [ + { id: 'e-start-source', source: 'start', target: 'source', type: EDGE_TYPE }, + { id: 'e-source-loop', source: 'source', target: 'outer-loop', type: EDGE_TYPE } + ] + }); + + expect(result.parallel?.filter((issue) => issue.inputKey === 'nested-input') ?? []).toEqual( + [] + ); + expect( + result['nested-loop']?.filter((issue) => issue.inputKey === 'custom-output') ?? [] + ).toEqual([]); + }); + + it('keeps nested descendants outside an outer loop custom output scope', () => { + const outerLoop = makeNode('outer-loop', FlowNodeTypeEnum.loopRun, { + inputs: [ + { + ...makeReferenceInput('custom-output', ['nested-child', 'out']), + canEdit: true + } + ] + }); + const nestedLoop = makeNode('nested-loop', FlowNodeTypeEnum.loopRun, { + parentNodeId: 'outer-loop' + }); + const nestedChild = makeOutputNode('nested-child'); + nestedChild.data.parentNodeId = 'nested-loop'; + + const result = checkWorkflowNodeIssues({ + nodes: [startNode, outerLoop, nestedLoop, nestedChild], + edges: [{ id: 'e-start-loop', source: 'start', target: 'outer-loop', type: EDGE_TYPE }] + }); + + expect(result['outer-loop']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'unreachable_reference', inputKey: 'custom-output' }) + ]) + ); + }); + it('distinguishes a missing source from an out-of-scope source', () => { const sourceNode = makeOutputNode('source'); const validNode = makeNode('valid', FlowNodeTypeEnum.answerNode, { @@ -929,22 +1026,33 @@ describe('checkWorkflowNodeIssues', () => { ] }); + const t = vi.fn((key: string, params?: { inputName?: string }) => { + if (key === 'common:core.workflow.variable') return 'Variable'; + if (key === 'common:value') return 'Value'; + if (key === 'common:core.workflow.check.invalid_reference') { + return `${params?.inputName} reference is invalid`; + } + return key; + }); const result = checkWorkflowNodeIssues({ nodes: [typedSourceNode, detachedSourceNode, ifElseNode], edges: [ { id: 'e-source-if-else', source: 'typed-source', target: 'if-else', type: EDGE_TYPE } - ] + ], + t: t as any }); expect(result['if-else']).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'invalid_reference', - inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[0].variable` + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[0].variable`, + message: 'Variable reference is invalid' }), expect.objectContaining({ code: 'invalid_reference', - inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[1].value` + inputKey: `${NodeInputKeyEnum.ifElseList}[0].list[1].value`, + message: 'Value reference is invalid' }), expect.objectContaining({ code: 'invalid_reference_type', From a98cd2c668dbe975fd45f0237ca0b990ba832b3b Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Thu, 3 Sep 2026 10:38:31 +0800 Subject: [PATCH 18/18] fix(workflow): preserve tool references --- .../global/core/workflow/runtime/utils.ts | 12 +- .../test/core/workflow/runtime/utils.test.ts | 31 +++++ .../core/workflow/dispatch/tools/http468.ts | 15 ++- .../workflow/dispatch/tools/http468.test.ts | 120 +++++++++++++++++- .../web/components/common/MyModal/index.tsx | 8 +- .../app/detail/Plugin/Header.tsx | 2 +- .../app/detail/Workflow/Header.tsx | 2 +- .../RenderInput/templates/Reference.tsx | 65 +++++++++- .../context/workflowActionsContext.tsx | 26 +++- .../context/workflowUtilsContext.tsx | 5 +- .../detail/WorkflowComponents/utils.test.ts | 48 +++++++ 11 files changed, 317 insertions(+), 17 deletions(-) diff --git a/packages/global/core/workflow/runtime/utils.ts b/packages/global/core/workflow/runtime/utils.ts index 0ec19d2beb43..547318c3c750 100644 --- a/packages/global/core/workflow/runtime/utils.ts +++ b/packages/global/core/workflow/runtime/utils.ts @@ -18,6 +18,7 @@ import { isValidReferenceValueFormat } from '../utils'; import type { RuntimeNodeItemType } from './type'; import { isSecretValue } from '../../../common/secret/utils'; import { isChildInteractive } from '../template/system/interactive/constants'; +import { isToolParamInput } from '../../app/formEdit/utils'; export const extractDeepestInteractive = ( interactive: WorkflowInteractiveResponseType @@ -333,7 +334,16 @@ export const getReferenceVariableValue = ({ return value; } - return node.outputs.find((output) => output.id === outputId)?.value; + const output = node.outputs.find((item) => item.id === outputId); + if (output) return output.value; + + // HTTP 工具参数是编辑器侧的虚拟输出,运行时值仍保存在节点输入中。 + if (node.flowNodeType === FlowNodeTypeEnum.httpRequest468) { + const input = node.inputs.find((item) => item.key === outputId && isToolParamInput(item)); + if (input) return input.value ?? input.defaultValue; + } + + return undefined; }; // handle single reference value diff --git a/packages/global/test/core/workflow/runtime/utils.test.ts b/packages/global/test/core/workflow/runtime/utils.test.ts index a77bca6151ad..05e133273649 100644 --- a/packages/global/test/core/workflow/runtime/utils.test.ts +++ b/packages/global/test/core/workflow/runtime/utils.test.ts @@ -20,6 +20,7 @@ import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { + FlowNodeInputTypeEnum, FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; @@ -1312,6 +1313,36 @@ describe('getReferenceVariableValue', () => { expect(result).toBe('outputValue'); }); + it('should resolve HTTP tool parameter values from node inputs', () => { + const nodesMap: Record = { + httpTool: { + nodeId: 'httpTool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'query', + renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], + selectedType: FlowNodeInputTypeEnum.agentGenerated, + canEdit: true, + defaultToAgentGenerated: true, + value: 'tool query', + valueType: WorkflowIOValueTypeEnum.string + } + ], + outputs: [] + } + }; + + expect( + getReferenceVariableValue({ + value: ['httpTool', 'query'], + nodesMap, + variables: {} + }) + ).toBe('tool query'); + }); + it('should return undefined when output id not found in node', () => { const nodesMap: Record = { node1: { diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index 659a8d187c5f..da49ecad2382 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -7,7 +7,10 @@ import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; @@ -22,6 +25,7 @@ import { isValidReferenceValueFormat, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils'; +import { isToolParamInput } from '@fastgpt/global/core/app/formEdit/utils'; import json5 from 'json5'; import { JSONPath } from 'jsonpath-plus'; import { getSecretValue } from '../../../../common/secret/utils'; @@ -138,11 +142,14 @@ export const filterHttpRuntimeParams = ({ const sourceNode = runtimeNodesMap.get(sourceNodeId); const output = sourceNode?.outputs.find((item) => item.id === outputId); - if (!sourceNode || !output || output.invalid === true || output.value === undefined) { - return false; + if (output) { + if (output.invalid === true || output.value === undefined) return false; + return output.type !== FlowNodeOutputTypeEnum.error || sourceNode?.catchError === true; } - return output.type !== FlowNodeOutputTypeEnum.error || sourceNode.catchError === true; + if (sourceNode?.flowNodeType !== FlowNodeTypeEnum.httpRequest468) return false; + const input = sourceNode.inputs.find((item) => item.key === outputId && isToolParamInput(item)); + return input?.value !== undefined || input?.defaultValue !== undefined; }; return Object.fromEntries( diff --git a/packages/service/test/core/workflow/dispatch/tools/http468.test.ts b/packages/service/test/core/workflow/dispatch/tools/http468.test.ts index 536a6db16f99..58e14f707c01 100644 --- a/packages/service/test/core/workflow/dispatch/tools/http468.test.ts +++ b/packages/service/test/core/workflow/dispatch/tools/http468.test.ts @@ -8,7 +8,8 @@ import { } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeInputTypeEnum, - FlowNodeOutputTypeEnum + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; const axiosMock = vi.hoisted(() => vi.fn()); @@ -30,6 +31,7 @@ import { dispatchHttp468Request, filterHttpRuntimeParams } from '@fastgpt/service/core/workflow/dispatch/tools/http468'; +import { getWorkflowNodeRunParams } from '@fastgpt/service/core/workflow/dispatch/utils/runtime'; const buildProps = (httpContentType: ContentTypes) => ({ @@ -93,6 +95,78 @@ describe('dispatchHttp468Request empty keys', () => { ); }); +describe('dispatchHttp468Request HTTP tool parameter references', () => { + beforeEach(() => { + axiosMock.mockReset(); + axiosMock.mockResolvedValue({ data: {} }); + isInternalAddressMock.mockReset(); + isInternalAddressMock.mockResolvedValue(false); + }); + + it('resolves a referenced HTTP tool parameter before building the request', async () => { + const node = { + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: NodeInputKeyEnum.httpReqUrl, + value: 'https://example.com', + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + selectedType: FlowNodeInputTypeEnum.input + }, + { + key: NodeInputKeyEnum.httpParams, + value: [{ key: 'forwarded', type: 'string', value: '{{toolParam}}' }], + valueType: WorkflowIOValueTypeEnum.arrayString, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + selectedType: FlowNodeInputTypeEnum.hidden + }, + { + key: 'toolParam', + value: ['http-tool', 'query'], + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference + } + ], + outputs: [] + } as any; + const runtimeNodesMap = new Map([ + [ + 'http-tool', + { + nodeId: 'http-tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'query', + renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], + selectedType: FlowNodeInputTypeEnum.agentGenerated, + canEdit: true, + defaultToAgentGenerated: true, + value: 'tool query', + valueType: WorkflowIOValueTypeEnum.string + } + ], + outputs: [] + } + ] + ]) as any; + const variableState = { toRuntimeRecord: () => ({}) }; + const params = getWorkflowNodeRunParams({ node, runtimeNodesMap, variableState }); + + await dispatchHttp468Request({ + ...buildProps(ContentTypes.json), + node, + runtimeNodesMap, + variableState, + params + }); + + expect(axiosMock.mock.calls[0][0].params).toEqual({ forwarded: 'tool query' }); + }); +}); + describe('filterHttpRuntimeParams', () => { it('removes deprecated containers and invalid references while preserving usable params', () => { const node = { @@ -265,4 +339,48 @@ describe('filterHttpRuntimeParams', () => { }) ).toEqual({ validGlobal: 'global value' }); }); + + it('keeps HTTP tool parameter references when filtering mixed values', () => { + const node = { + inputs: [ + { + key: 'mixedToolReference', + renderTypeList: [FlowNodeInputTypeEnum.reference], + selectedType: FlowNodeInputTypeEnum.reference, + value: [ + ['http-tool', 'query'], + ['missing', 'value'] + ], + valueType: WorkflowIOValueTypeEnum.arrayString + } + ] + } as any; + const httpToolNode = { + nodeId: 'http-tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [ + { + key: 'query', + renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], + selectedType: FlowNodeInputTypeEnum.agentGenerated, + canEdit: true, + defaultToAgentGenerated: true, + value: 'tool query', + valueType: WorkflowIOValueTypeEnum.string + } + ], + outputs: [] + } as any; + + expect( + filterHttpRuntimeParams({ + params: { + mixedToolReference: ['tool query', ['missing', 'value']] + }, + node, + runtimeNodesMap: new Map([['http-tool', httpToolNode]]), + variables: {} + }) + ).toEqual({ mixedToolReference: ['tool query'] }); + }); }); diff --git a/packages/web/components/common/MyModal/index.tsx b/packages/web/components/common/MyModal/index.tsx index 59f28949715b..0bfbc2623056 100644 --- a/packages/web/components/common/MyModal/index.tsx +++ b/packages/web/components/common/MyModal/index.tsx @@ -100,7 +100,13 @@ const MyModal = ({ {onClose && ( - + )} )} diff --git a/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx b/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx index 0ce5dc220cf7..b53425fbf07f 100644 --- a/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/Plugin/Header.tsx @@ -249,7 +249,7 @@ const Header = () => { title={t('common:Exit')} w={'400px'} > - + {t('workflow:workflow.exit_tips')} diff --git a/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx b/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx index 8cbc86943355..a3c6d850ba5f 100644 --- a/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx +++ b/projects/app/src/pageComponents/app/detail/Workflow/Header.tsx @@ -249,7 +249,7 @@ const Header = () => { title={t('common:Exit')} w={'400px'} > - + {t('workflow:workflow.exit_tips')} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 46ea02fd1bd5..33a9c5843fd6 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -173,7 +173,68 @@ const ReferenceSnapshotLabel = ({ t: ReturnType['t']; }) => { const sourceLabel = snapshot?.sourceLabel; - const outputLabel = snapshot?.outputLabel ? t(snapshot.outputLabel as any) : fallback; + const outputLabel = (() => { + switch (snapshot?.outputLabel) { + case 'workflow:Array_element_index': + return t('workflow:Array_element_index'); + case 'workflow:error_text': + return t('workflow:error_text'); + case 'workflow:loop_result': + return t('workflow:loop_result'); + case 'common:core.module.output.label.query extension result': + return t('common:core.module.output.label.query extension result'); + case 'workflow:concatenation_result': + return t('workflow:concatenation_result'); + case 'common:core.module.Dataset quote.label': + return t('common:core.module.Dataset quote.label'); + case 'common:core.module.output.label.Ai response content': + return t('common:core.module.output.label.Ai response content'); + case 'workflow:current_index': + return t('workflow:current_index'); + case 'workflow:current_item': + return t('workflow:current_item'); + case 'workflow:current_iteration': + return t('workflow:current_iteration'); + case 'workflow:parallel_success_results': + return t('workflow:parallel_success_results'); + case 'workflow:parallel_full_results': + return t('workflow:parallel_full_results'); + case 'workflow:parallel_status': + return t('workflow:parallel_status'); + case 'workflow:new_context': + return t('workflow:new_context'); + case 'workflow:judgment_result': + return t('workflow:judgment_result'); + case 'workflow:full_field_extraction': + return t('workflow:full_field_extraction'); + case 'workflow:complete_extraction_result': + return t('workflow:complete_extraction_result'); + case 'workflow:classification_result': + return t('workflow:classification_result'); + case 'app:workflow.select_result': + return t('app:workflow.select_result'); + case 'workflow:form_input_result': + return t('workflow:form_input_result'); + case 'common:core.module.output.label.New context': + return t('common:core.module.output.label.New context'); + case 'workflow:http_extract_output': + return t('workflow:http_extract_output'); + case 'workflow:raw_response': + return t('workflow:raw_response'); + case 'workflow:http_full_error': + return t('workflow:http_full_error'); + case 'app:workflow.read_files_result': + return t('app:workflow.read_files_result'); + case 'workflow:full_response_data': + return t('workflow:full_response_data'); + case 'workflow:reply_text': + return t('workflow:reply_text'); + case 'common:core.module.input.label.user question': + return t('common:core.module.input.label.user question'); + default: + return snapshot?.outputLabel || fallback; + } + })(); if (sourceLabel && outputLabel) { return ( @@ -439,7 +500,7 @@ const SingleReferenceSelector = ({ aria-label={invalidReason} onClick={(event) => event.stopPropagation()} > - + ) : undefined 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 a4a50b76db1a..ea0d1cae0632 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext.tsx @@ -4,9 +4,9 @@ import { createContext, useContextSelector } from 'use-context-selector'; import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { getHandleId } from '@fastgpt/global/core/workflow/utils'; -import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { migrateToolInputConfig } from '@fastgpt/global/core/app/formEdit/utils'; -import type { OnConnectStartParams } from 'reactflow'; +import type { Edge, OnConnectStartParams } from 'reactflow'; import { WorkflowBufferDataContext } from './workflowInitContext'; import type { FlowNodeInputItemType, @@ -69,6 +69,13 @@ type FlowNodeChangeProps = { nodeId: string } & ( } ); +const getToolNodeIds = (edges: Edge[]) => + new Set( + edges + .filter((edge) => edge.targetHandle === NodeOutputKeyEnum.selectedTools) + .map((edge) => edge.target) + ); + // 创建 Context type WorkflowActionsContextValue = { /** 更新节点错误状态 */ @@ -452,19 +459,23 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }; }); + const toolNodeIds = getToolNodeIds(edges); + return captureDeletedWorkflowReferenceSnapshots({ previousNodes: state, nextNodes, previousChatConfig: appDetail.chatConfig, nextChatConfig: appDetail.chatConfig, globalVariableSourceLabel: t('common:core.module.Variable'), - nodeIds: [id] + nodeIds: [id], + previousToolNodeIds: toolNodeIds, + nextToolNodeIds: toolNodeIds }); }); scheduleFullWorkflowCheck(); }, - [appDetail.chatConfig, forbiddenSaveSnapshotRef, scheduleFullWorkflowCheck, setNodes, t] + [appDetail.chatConfig, edges, forbiddenSaveSnapshotRef, scheduleFullWorkflowCheck, setNodes, t] ); // 使用结构共享优化的节点更改 @@ -618,13 +629,17 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod }; }); + const toolNodeIds = getToolNodeIds(edges); + return captureDeletedWorkflowReferenceSnapshots({ previousNodes: nodes, nextNodes, previousChatConfig: appDetail.chatConfig, nextChatConfig: appDetail.chatConfig, globalVariableSourceLabel: t('common:core.module.Variable'), - nodeIds: nodeIdsToRecheck + nodeIds: nodeIdsToRecheck, + previousToolNodeIds: toolNodeIds, + nextToolNodeIds: toolNodeIds }); }); @@ -660,6 +675,7 @@ export const WorkflowActionsProvider = ({ children }: { children: React.ReactNod t, onDelEdge, llmModelMap, + edges, scheduleSingleNodeWorkflowCheck, scheduleFullWorkflowCheck, appDetail.chatConfig 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 fa01f0563d59..fd21f39a4bb7 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -6,7 +6,10 @@ import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { captureDeletedWorkflowReferenceSnapshots } from '@/web/core/workflow/referenceCheck'; import { storeNode2FlowNode, storeEdge2RenderEdge } from '@/web/core/workflow/utils'; -import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck'; +import { + checkWorkflowBeforeRunOrPublish, + checkWorkflowNodeIssues +} from '@/web/core/workflow/workflowCheck'; import { uiWorkflow2StoreWorkflow } from '../utils'; import { FlowNodeOutputTypeEnum, diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts index f38f6d3a5e6a..61feb29d0e32 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts @@ -1559,6 +1559,54 @@ describe('WorkflowComponents utils', () => { ]); }); + it('captures a deleted HTTP tool parameter while the tool edge remains', () => { + const toolInput = { + key: 'query', + label: 'Query parameter', + renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], + selectedType: FlowNodeInputTypeEnum.agentGenerated, + canEdit: true, + defaultToAgentGenerated: true, + valueType: WorkflowIOValueTypeEnum.string, + value: 'query' + }; + const tool = createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + inputs: [toolInput] + }); + const consumer = createNode({ + nodeId: 'consumer', + name: 'Consumer', + inputs: [createReferenceInput('input', ['http-tool', 'query'])] + }); + + const result = captureDeletedWorkflowReferenceSnapshots({ + previousNodes: [tool, consumer], + nextNodes: [ + createNode({ + nodeId: 'http-tool', + name: 'HTTP tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468 + }), + consumer + ], + previousChatConfig: {}, + nextChatConfig: {}, + previousToolNodeIds: new Set(['http-tool']), + nextToolNodeIds: new Set(['http-tool']) + }); + + expect(result[1].data.inputs[0].referenceSnapshots).toEqual([ + { + reference: ['http-tool', 'query'], + sourceLabel: 'HTTP tool', + outputLabel: 'Query parameter' + } + ]); + }); + it('captures deleted global references inside VariableUpdate input values', () => { const consumer = createNode({ nodeId: 'consumer',