mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
feat: Add subgraph output validation for single-run debugging
This commit is contained in:
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user