mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
feat: Add subgraph output validation for single-run debugging
This commit is contained in:
parent
135fc45ae9
commit
7408405c91
@ -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'
|
||||
|
||||
79
web/app/components/workflow/hooks/use-sub-graph-by-parent.ts
Normal file
79
web/app/components/workflow/hooks/use-sub-graph-by-parent.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1030,6 +1030,7 @@
|
||||
"singleRun.reRun": "再実行",
|
||||
"singleRun.running": "実行中",
|
||||
"singleRun.startRun": "実行開始",
|
||||
"singleRun.subgraph.nullOutputError": "サブグラフの出力にnullが含まれているため、単体デバッグできません。依存ノードを先に実行してください。",
|
||||
"singleRun.testRun": "テスト実行",
|
||||
"singleRun.testRunIteration": "テスト実行(イテレーション)",
|
||||
"singleRun.testRunLoop": "テスト実行ループ",
|
||||
|
||||
@ -1050,6 +1050,7 @@
|
||||
"singleRun.reRun": "重新运行",
|
||||
"singleRun.running": "运行中",
|
||||
"singleRun.startRun": "开始运行",
|
||||
"singleRun.subgraph.nullOutputError": "子图输出包含空值,无法单步调试。请先运行依赖节点。",
|
||||
"singleRun.testRun": "测试运行",
|
||||
"singleRun.testRunIteration": "测试运行迭代",
|
||||
"singleRun.testRunLoop": "测试运行循环",
|
||||
|
||||
@ -1031,6 +1031,7 @@
|
||||
"singleRun.reRun": "重新運行",
|
||||
"singleRun.running": "運行中",
|
||||
"singleRun.startRun": "開始運行",
|
||||
"singleRun.subgraph.nullOutputError": "子圖輸出包含空值,無法單步調試。請先執行依賴節點。",
|
||||
"singleRun.testRun": "測試運行",
|
||||
"singleRun.testRunIteration": "測試運行迭代",
|
||||
"singleRun.testRunLoop": "測試運行循環",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user