From 7408405c9103bfe12db3a6ca7433eecf21ad71e4 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 28 Jan 2026 16:34:51 +0800 Subject: [PATCH] feat: Add subgraph output validation for single-run debugging --- web/app/components/workflow/hooks/index.ts | 1 + .../workflow/hooks/use-sub-graph-by-parent.ts | 79 +++++++++++++ .../last-run/sub-graph-variables-check.ts | 105 ++++++++++++++++++ .../workflow-panel/last-run/use-last-run.ts | 58 ++++++---- .../components/chat-view.tsx | 1 + .../nodes/tool/use-single-run-form-params.ts | 71 +++++++++++- web/i18n/en-US/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 10 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 web/app/components/workflow/hooks/use-sub-graph-by-parent.ts create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/sub-graph-variables-check.ts diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index f57a44269e..76f60df00e 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -17,6 +17,7 @@ export * from './use-serial-async-callback' export * from './use-serial-async-callback' export * from './use-set-workflow-vars-with-value' export * from './use-shortcuts' +export * from './use-sub-graph-by-parent' export * from './use-tool-icon' export * from './use-workflow' export * from './use-workflow-comment' diff --git a/web/app/components/workflow/hooks/use-sub-graph-by-parent.ts b/web/app/components/workflow/hooks/use-sub-graph-by-parent.ts new file mode 100644 index 0000000000..53dda32a23 --- /dev/null +++ b/web/app/components/workflow/hooks/use-sub-graph-by-parent.ts @@ -0,0 +1,79 @@ +import type { + Node, + NodeOutPutVar, + ValueSelector, + Var, +} from '../types' +import { useMemo } from 'react' +import { useStore as useReactFlowStore } from 'reactflow' +import { useShallow } from 'zustand/react/shallow' +import { useIsChatMode } from './use-workflow' +import { useWorkflowVariables } from './use-workflow-variables' + +type SubGraphNodesByParentResult = { + subGraphNodes: Node[] + subGraphNodeIds: string[] +} + +type SubGraphOutputVarsByParentOptions = { + filterVar?: (payload: Var, selector: ValueSelector) => boolean +} + +type SubGraphOutputVarsByParentResult = SubGraphNodesByParentResult & { + subGraphOutputVars: NodeOutPutVar[] +} + +const defaultFilterVar = () => true + +export const useSubGraphNodesByParent = (parentNodeId?: string): SubGraphNodesByParentResult => { + const nodes = useReactFlowStore(useShallow(state => state.getNodes())) + + return useMemo(() => { + if (!parentNodeId) + return { subGraphNodes: [], subGraphNodeIds: [] } + + const subGraphNodes = nodes.filter((node) => { + const parentId = node.data.parent_node_id + if (parentId === parentNodeId) + return true + // Reason: fallback for legacy nested nodes missing parent_node_id. + if (!parentId && node.id.startsWith(`${parentNodeId}_ext_`)) + return true + return false + }) + + return { + subGraphNodes, + subGraphNodeIds: subGraphNodes.map(node => node.id), + } + }, [nodes, parentNodeId]) +} + +export const useSubGraphOutputVarsByParent = ( + parentNodeId?: string, + options?: SubGraphOutputVarsByParentOptions, +): SubGraphOutputVarsByParentResult => { + const { subGraphNodes, subGraphNodeIds } = useSubGraphNodesByParent(parentNodeId) + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() + const filterVar = options?.filterVar ?? defaultFilterVar + + const subGraphOutputVars = useMemo(() => { + if (!subGraphNodes.length) + return [] + + const vars = getNodeAvailableVars({ + beforeNodes: subGraphNodes, + isChatMode, + filterVar, + }) + const nodeIdSet = new Set(subGraphNodeIds) + return vars.filter(item => nodeIdSet.has(item.nodeId)) + }, [filterVar, getNodeAvailableVars, isChatMode, subGraphNodeIds, subGraphNodes]) + + return { + subGraphNodes, + subGraphNodeIds, + subGraphOutputVars, + } +} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/sub-graph-variables-check.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/sub-graph-variables-check.ts new file mode 100644 index 0000000000..d79f58b9b4 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/sub-graph-variables-check.ts @@ -0,0 +1,105 @@ +import type { ValueSelector } from '@/app/components/workflow/types' +import type { NodeWithVar } from '@/types/workflow' +import { useCallback } from 'react' +import { useSubGraphNodesByParent } from '@/app/components/workflow/hooks' +import { + isConversationVar, + isENV, + isRagVariableVar, + isSystemVar, +} from '@/app/components/workflow/nodes/_base/components/variable/utils' + +type Params = { + currentNodeId: string + nodesWithInspectVars: NodeWithVar[] +} + +const resolveNestedValue = (value: unknown, path: string[]) => { + if (!path.length) + return value + // Reason: inspect vars store top-level values; nested selectors need safe traversal. + let current: unknown = value + for (const key of path) { + if (current === null || current === undefined) + return undefined + if (Array.isArray(current)) { + const index = Number(key) + if (!Number.isInteger(index)) + return undefined + current = current[index] + continue + } + if (typeof current === 'object') { + current = (current as Record)[key] + continue + } + return undefined + } + return current +} + +export const useSubGraphVariablesCheck = ({ + currentNodeId, + nodesWithInspectVars, +}: Params) => { + const { subGraphNodeIds } = useSubGraphNodesByParent(currentNodeId) + + const getInspectVarValueBySelector = useCallback((selector: ValueSelector) => { + if (!selector || selector.length < 2) + return { found: false, value: undefined } + if (selector[0] === currentNodeId) + return { found: false, value: undefined } + if (isENV(selector) || isSystemVar(selector) || isConversationVar(selector) || isRagVariableVar(selector)) + return { found: false, value: undefined } + + const [nodeId, varName, ...restPath] = selector + const nodeVars = nodesWithInspectVars.find(node => node.nodeId === nodeId)?.vars || [] + if (!nodeVars.length) + return { found: false, value: undefined } + + const selectorKey = selector.join('.') + const varBySelector = nodeVars.find(item => item.selector?.join('.') === selectorKey) + const varByName = nodeVars.find(item => item.selector?.[1] === varName || item.name === varName) + const targetVar = varBySelector || varByName + if (!targetVar) + return { found: false, value: undefined } + + if (!restPath.length) + return { found: true, value: targetVar.value } + + return { + found: true, + value: resolveNestedValue(targetVar.value, restPath), + } + }, [currentNodeId, nodesWithInspectVars]) + + const hasNullDependentOutputs = useCallback((vars?: ValueSelector[] | ValueSelector[][]) => { + if (!vars || vars.length === 0) + return false + + const isGroupedVars = Array.isArray(vars[0]) && Array.isArray((vars as ValueSelector[][])[0][0]) + const selectors = isGroupedVars ? (vars as ValueSelector[][]).flat() : (vars as ValueSelector[]) + const subGraphNodeIdSet = new Set(subGraphNodeIds) + const details = selectors.map((selector) => { + const { found, value } = getInspectVarValueBySelector(selector) + const valueType = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value + const isSubgraphOutput = subGraphNodeIdSet.has(selector[0]) + return { + selector, + found, + valueType, + isSubgraphOutput, + } + }) + const hasNull = details.some((item) => { + if (!item.found) + return item.isSubgraphOutput + return item.valueType === 'null' || item.valueType === 'undefined' + }) + return hasNull + }, [getInspectVarValueBySelector, subGraphNodeIds]) + + return { + hasNullDependentOutputs, + } +} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 1aa11568e4..8c5e33f9a6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -11,6 +11,9 @@ import { } from '@/app/components/workflow/hooks' import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { + useSubGraphVariablesCheck, +} from '@/app/components/workflow/nodes/_base/components/workflow-panel/last-run/sub-graph-variables-check' import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run' import useAgentSingleRunFormParams from '@/app/components/workflow/nodes/agent/use-single-run-form-params' import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/nodes/assigner/use-single-run-form-params' @@ -115,8 +118,8 @@ const getDataForCheckMoreHooks: Record = { } const useGetDataForCheckMoreHooks = (nodeType: BlockEnum) => { - return (id: string, payload: CommonNodeType) => { - return getDataForCheckMoreHooks[nodeType]?.({ id, payload }) || { + return (nodeId: string, payload: CommonNodeType) => { + return getDataForCheckMoreHooks[nodeType]?.({ id: nodeId, payload }) || { getData: () => { return {} }, @@ -128,7 +131,20 @@ type Params = Omit, 'isRunAfterSingleRun'> const useLastRun = ({ ...oneStepRunParams }: Params) => { - const { conversationVars, systemVars, hasSetInspectVar } = useInspectVarsCrud() + const currentNodeId = oneStepRunParams.id + const flowId = oneStepRunParams.flowId + const flowType = oneStepRunParams.flowType + const data = oneStepRunParams.data + const { + conversationVars, + systemVars, + hasSetInspectVar, + nodesWithInspectVars, + } = useInspectVarsCrud() + const { hasNullDependentOutputs } = useSubGraphVariablesCheck({ + currentNodeId, + nodesWithInspectVars, + }) const { t } = useTranslation() const blockType = oneStepRunParams.data.type const isStartNode = blockType === BlockEnum.Start @@ -139,32 +155,26 @@ const useLastRun = ({ const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { getData: getDataForCheckMore, - } = useGetDataForCheckMoreHooks(blockType)(oneStepRunParams.id, oneStepRunParams.data) + } = useGetDataForCheckMoreHooks(blockType)(currentNodeId, oneStepRunParams.data) const [isRunAfterSingleRun, setIsRunAfterSingleRun] = useState(false) - const { - id, - flowId, - flowType, - data, - } = oneStepRunParams const oneStepRunRes = useOneStepRun({ ...oneStepRunParams, - iteratorInputKey: blockType === BlockEnum.Iteration ? `${id}.input_selector` : '', + iteratorInputKey: blockType === BlockEnum.Iteration ? `${currentNodeId}.input_selector` : '', moreDataForCheckValid: getDataForCheckMore(), isRunAfterSingleRun, }) const { warningNodes } = useWorkflowRunValidation() const blockIfChecklistFailed = useCallback(() => { - const warningForNode = warningNodes.find(item => item.id === id) + const warningForNode = warningNodes.find(item => item.id === currentNodeId) if (!warningForNode) return false const message = warningForNode.errorMessage || 'This node has unresolved checklist issues' Toast.notify({ type: 'error', message }) return true - }, [warningNodes, id]) + }, [warningNodes, currentNodeId]) const { hideSingleRun, @@ -187,7 +197,7 @@ const useLastRun = ({ const { ...singleRunParams } = useSingleRunFormParamsHooks(blockType)({ - id, + id: currentNodeId, payload: data, runInputData, runInputDataRef, @@ -211,11 +221,11 @@ const useLastRun = ({ formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr] }) if (isIterationNode) { - const iteratorInputKey = `${id}.input_selector` + const iteratorInputKey = `${currentNodeId}.input_selector` formattedData[iteratorInputKey] = data[iteratorInputKey] } return formattedData - }, [isIterationNode, isLoopNode, singleRunParams?.allVarObject, id]) + }, [isIterationNode, isLoopNode, singleRunParams?.allVarObject, currentNodeId]) const callRunApi = (data: Record, cb?: () => void) => { handleSyncWorkflowDraft(true, true, { @@ -235,7 +245,7 @@ const useLastRun = ({ setInitShowLastRunTab(false) }, [initShowLastRunTab]) - const invalidLastRun = useInvalidLastRun(flowType, flowId, id) + const invalidLastRun = useInvalidLastRun(flowType, flowId, currentNodeId) const ensureLLMContextReady = useCallback(() => { if (blockType !== BlockEnum.LLM) @@ -257,6 +267,11 @@ const useLastRun = ({ return if (!ensureLLMContextReady()) return + const dependentVars = singleRunParams?.getDependentVars?.() + if (hasNullDependentOutputs(dependentVars)) { + Toast.notify({ type: 'error', message: t('singleRun.subgraph.nullOutputError', { ns: 'workflow' }) }) + return + } setNodeRunning() setIsRunAfterSingleRun(true) setTabType(TabType.lastRun) @@ -286,7 +301,7 @@ const useLastRun = ({ if (!selector || selector.length === 0) return const [nodeId, varName] = selector.slice(0, 2) - if (!isStartNode && nodeId === id) { // inner vars like loop vars + if (!isStartNode && nodeId === currentNodeId) { // inner vars like loop vars values[variable] = true return } @@ -359,13 +374,18 @@ const useLastRun = ({ return if (!ensureLLMContextReady()) return + const dependentVars = singleRunParams?.getDependentVars?.() + if (hasNullDependentOutputs(dependentVars)) { + Toast.notify({ type: 'error', message: t('singleRun.subgraph.nullOutputError', { ns: 'workflow' }) }) + return + } if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule) setShowVariableInspectPanel(true) if (isCustomRunNode) { showSingleRun() return } - const vars = singleRunParams?.getDependentVars?.() + const vars = dependentVars // no need to input params if (isAggregatorNode ? checkAggregatorVarsSet(vars) : isAllVarsHasValue(vars)) { callRunApi({}, async () => { diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/chat-view.tsx b/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/chat-view.tsx index 68dd732f15..883be559da 100644 --- a/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/chat-view.tsx +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/chat-view.tsx @@ -68,6 +68,7 @@ const ChatView = ({
{(() => { let assistantIndex = -1 + // FIXME: delete these hard coded values assistant return promptMessages.map((message, index) => { if (message.role === 'assistant') assistantIndex += 1 diff --git a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts index e060b42ed5..1e277b85fd 100644 --- a/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/tool/use-single-run-form-params.ts @@ -7,6 +7,7 @@ import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log' +import { AGENT_CONTEXT_VAR_PATTERN } from '@/app/components/workflow/utils/agent-context' import { useToolIcon } from '../../hooks' import useNodeCrud from '../_base/hooks/use-node-crud' import { VarType } from './types' @@ -21,6 +22,14 @@ type Params = { toVarInputs: (variables: Variable[]) => InputVar[] runResult: NodeTracing } +type NestedNodeParam = { + type?: VarType + value?: unknown + nested_node_config?: { + extractor_node_id?: string + output_selector?: unknown + } +} const useSingleRunFormParams = ({ id, payload, @@ -87,14 +96,68 @@ const useSingleRunFormParams = ({ const toolIcon = useToolIcon(payload) + const resolveOutputSelector = (extractorNodeId: string, rawSelector?: unknown) => { + if (!Array.isArray(rawSelector)) + return [] as string[] + if (rawSelector[0] === extractorNodeId) + return rawSelector.slice(1) as string[] + return rawSelector as string[] + } + + const getDefaultNestedOutputSelector = (paramKey: string, value?: unknown) => { + if (typeof value === 'string') { + const matches = Array.from(value.matchAll(AGENT_CONTEXT_VAR_PATTERN)) + if (matches.length > 0) + return ['structured_output', paramKey] + } + return ['result'] + } + + const collectNestedNodeSelectors = (params: Record = {}) => { + return Object.entries(params).flatMap(([paramKey, param]) => { + if (!param || param.type !== VarType.nested_node) + return [] as ValueSelector[] + + const nestedConfig = param.nested_node_config + const extractorNodeId = nestedConfig?.extractor_node_id || `${id}_ext_${paramKey}` + const rawSelector = nestedConfig?.output_selector + const resolvedOutputSelector = resolveOutputSelector(extractorNodeId, rawSelector) + const outputSelector = resolvedOutputSelector.length > 0 + ? resolvedOutputSelector + : getDefaultNestedOutputSelector(paramKey, param.value) + + return outputSelector.length > 0 + ? [[extractorNodeId, ...outputSelector]] + : [] + }) + } + const getDependentVars = () => { - return varInputs.map((item) => { + const selectorList: ValueSelector[] = [] + + varInputs.forEach((item) => { // Guard against null/undefined variable to prevent app crash if (!item.variable || typeof item.variable !== 'string') - return [] + return + const selector = item.variable.slice(1, -1).split('.') + if (selector.length > 0) + selectorList.push(selector) + }) - return item.variable.slice(1, -1).split('.') - }).filter(arr => arr.length > 0) + const nestedSelectors = [ + ...collectNestedNodeSelectors(inputs.tool_parameters as Record), + ...collectNestedNodeSelectors(inputs.tool_configurations as Record), + ] + selectorList.push(...nestedSelectors) + + const seen = new Set() + return selectorList.filter((selector) => { + const key = selector.join('.') + if (seen.has(key)) + return false + seen.add(key) + return true + }) } return { diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 318d4621c6..c3ed1cdc9d 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1058,6 +1058,7 @@ "singleRun.reRun": "Re-run", "singleRun.running": "Running", "singleRun.startRun": "Start Run", + "singleRun.subgraph.nullOutputError": "Subgraph outputs contain null values. Run dependent nodes first.", "singleRun.testRun": "Test Run", "singleRun.testRunIteration": "Test Run Iteration", "singleRun.testRunLoop": "Test Run Loop", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 8771b80367..32fef9065f 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -1030,6 +1030,7 @@ "singleRun.reRun": "再実行", "singleRun.running": "実行中", "singleRun.startRun": "実行開始", + "singleRun.subgraph.nullOutputError": "サブグラフの出力にnullが含まれているため、単体デバッグできません。依存ノードを先に実行してください。", "singleRun.testRun": "テスト実行", "singleRun.testRunIteration": "テスト実行(イテレーション)", "singleRun.testRunLoop": "テスト実行ループ", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 9cff52ad67..f88cc858d6 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1050,6 +1050,7 @@ "singleRun.reRun": "重新运行", "singleRun.running": "运行中", "singleRun.startRun": "开始运行", + "singleRun.subgraph.nullOutputError": "子图输出包含空值,无法单步调试。请先运行依赖节点。", "singleRun.testRun": "测试运行", "singleRun.testRunIteration": "测试运行迭代", "singleRun.testRunLoop": "测试运行循环", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index d278dfe457..d4e98ac766 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -1031,6 +1031,7 @@ "singleRun.reRun": "重新運行", "singleRun.running": "運行中", "singleRun.startRun": "開始運行", + "singleRun.subgraph.nullOutputError": "子圖輸出包含空值,無法單步調試。請先執行依賴節點。", "singleRun.testRun": "測試運行", "singleRun.testRunIteration": "測試運行迭代", "singleRun.testRunLoop": "測試運行循環",