feat: Add subgraph output validation for single-run debugging

This commit is contained in:
zhsama
2026-01-28 16:34:51 +08:00
parent 135fc45ae9
commit 7408405c91
10 changed files with 296 additions and 23 deletions

View File

@ -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<string, unknown>)[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,
}
}

View File

@ -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<BlockEnum, any> = {
}
const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => {
return (id: string, payload: CommonNodeType<T>) => {
return getDataForCheckMoreHooks[nodeType]?.({ id, payload }) || {
return (nodeId: string, payload: CommonNodeType<T>) => {
return getDataForCheckMoreHooks[nodeType]?.({ id: nodeId, payload }) || {
getData: () => {
return {}
},
@ -128,7 +131,20 @@ type Params<T> = Omit<OneStepRunParams<T>, 'isRunAfterSingleRun'>
const useLastRun = <T>({
...oneStepRunParams
}: Params<T>) => {
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 = <T>({
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const {
getData: getDataForCheckMore,
} = useGetDataForCheckMoreHooks<T>(blockType)(oneStepRunParams.id, oneStepRunParams.data)
} = useGetDataForCheckMoreHooks<T>(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 = <T>({
const {
...singleRunParams
} = useSingleRunFormParamsHooks(blockType)({
id,
id: currentNodeId,
payload: data,
runInputData,
runInputDataRef,
@ -211,11 +221,11 @@ const useLastRun = <T>({
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<string, any>, cb?: () => void) => {
handleSyncWorkflowDraft(true, true, {
@ -235,7 +245,7 @@ const useLastRun = <T>({
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 = <T>({
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 = <T>({
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 = <T>({
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 () => {

View File

@ -68,6 +68,7 @@ const ChatView = ({
<div className="flex w-full flex-col items-end gap-4 pt-3">
{(() => {
let assistantIndex = -1
// FIXME: delete these hard coded values assistant
return promptMessages.map((message, index) => {
if (message.role === 'assistant')
assistantIndex += 1

View File

@ -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<string, NestedNodeParam> = {}) => {
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<string, NestedNodeParam>),
...collectNestedNodeSelectors(inputs.tool_configurations as Record<string, NestedNodeParam>),
]
selectorList.push(...nestedSelectors)
const seen = new Set<string>()
return selectorList.filter((selector) => {
const key = selector.join('.')
if (seen.has(key))
return false
seen.add(key)
return true
})
}
return {