diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index 366400d4c49..2468f528df1 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -1,8 +1,11 @@ import type { CommonNodeType, Node } from '../../types' import type { ChecklistItem } from '../use-checklist' +import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { QueryClient } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import { createElement, Fragment } from 'react' import { CollectionType } from '@/app/components/tools/types' +import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures' import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' @@ -146,6 +149,10 @@ function setupNodesMap() { checkValid: () => ({ errorMessage: '' }), metaData: { isStart: false, isRequired: false }, } + mockNodesMap[BlockEnum.AgentV2] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } } beforeEach(() => { @@ -175,6 +182,82 @@ function buildConnectedGraph() { return { nodes, edges } } +function buildInlineAgentGraph({ + hasMissingFile, + hasMissingSkill, +}: { + hasMissingFile: boolean + hasMissingSkill: boolean +}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + }, + }) + const appId = 'app-id' + const nodeId = 'inline-agent-node' + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: appId, + node_id: nodeId, + }, + }, + }), + zWorkflowAgentComposerResponse.parse({ + agent_soul: { + config_files: [ + { file_kind: 'upload_file', name: 'available.pdf' }, + ...(hasMissingFile + ? [{ file_kind: 'upload_file', is_missing: true, name: 'missing.pdf' }] + : []), + ], + config_skills: [ + { name: 'Available Skill' }, + ...(hasMissingSkill ? [{ is_missing: true, name: 'Missing Skill' }] : []), + ], + }, + node_job: {}, + save_options: [], + soul_lock: { locked: false }, + variant: 'workflow', + }), + ) + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const agentNode = createNode({ + id: nodeId, + data: { + type: BlockEnum.AgentV2, + title: 'Inline Agent', + agent_node_kind: 'dify_agent', + version: '2', + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-id', + current_snapshot_id: 'snapshot-id', + }, + }, + }) + + return { + edges: [createEdge({ source: 'start', target: nodeId })], + nodeId, + nodes: [startNode, agentNode], + options: { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: appId, + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }, + } +} + // --------------------------------------------------------------------------- // useChecklist // --------------------------------------------------------------------------- @@ -222,6 +305,43 @@ describe('useChecklist', () => { expect(warning!.errorMessages).toContain('Model not configured') }) + it.each([ + { + errorMessage: 'agentV2.agentDetail.configure.files.missing', + hasMissingFile: true, + hasMissingSkill: false, + referenceType: 'file', + }, + { + errorMessage: 'agentV2.agentDetail.configure.skills.missing', + hasMissingFile: false, + hasMissingSkill: true, + referenceType: 'skill', + }, + ])('should report a missing $referenceType reference from inline agents', async (scenario) => { + const { edges, nodeId, nodes, options } = buildInlineAgentGraph(scenario) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + await waitFor(() => { + expect(result.current).toEqual([ + expect.objectContaining({ + id: nodeId, + errorMessages: [scenario.errorMessage], + }), + ]) + }) + }) + + it('should not report available file and skill references from inline agents', () => { + const { edges, nodes, options } = buildInlineAgentGraph({ + hasMissingFile: false, + hasMissingSkill: false, + }) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + expect(result.current).toEqual([]) + }) + it('should pass flow type to node validators', () => { const checkValid = vi.fn(() => ({ errorMessage: '' })) mockNodesMap[BlockEnum.LLM] = { diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 1176b344297..c1f4587467e 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -15,7 +15,6 @@ import type { import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Emoji } from '@/app/components/tools/types' import type { DataSet } from '@/models/datasets' -import type { FlowType } from '@/types/common' import type { I18nKeysWithPrefix } from '@/types/i18n' import { toast } from '@langgenius/dify-ui/toast' import { useQueries, useQueryClient } from '@tanstack/react-query' @@ -42,12 +41,13 @@ import { } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' import { CUSTOM_NODE } from '../constants' import { useDatasetsDetailStore } from '../datasets-detail-store/store' import { useGetToolIcon, useNodesMetaData } from '../hooks' import { useHooksStore } from '../hooks-store/store' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' -import { isAgentV2NodeData } from '../nodes/agent-v2/types' +import { hasValidInlineAgentBinding, isAgentV2NodeData } from '../nodes/agent-v2/types' import { IndexMethodEnum } from '../nodes/knowledge-base/types' import { getLLMModelIssue, @@ -152,8 +152,71 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT const modelProviders = useProviderContextSelector((s) => s.modelProviders) const workflowStore = useWorkflowStore() + const configsMap = useHooksStore((s) => s.configsMap) const map = useNodesAvailableVarList(nodes) + const inlineAgentNodes = useMemo( + () => + nodes.filter( + (node) => + node.type === CUSTOM_NODE && + isAgentV2NodeData(node.data) && + hasValidInlineAgentBinding(node.data), + ), + [nodes], + ) + const inlineAgentMissingReferences = useQueries({ + queries: + !configsMap?.flowId || + (configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet) + ? [] + : inlineAgentNodes.map((node) => + configsMap.flowType === FlowType.snippet + ? consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions( + { + input: { + params: { + snippet_id: configsMap.flowId, + node_id: node.id, + }, + }, + }, + ) + : consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions( + { + input: { + params: { + app_id: configsMap.flowId, + node_id: node.id, + }, + }, + }, + ), + ), + combine: (results) => { + const missingReferences: Record< + string, + { hasMissingFiles: boolean; hasMissingSkills: boolean } + > = {} + + results.forEach((result, index) => { + const nodeId = inlineAgentNodes[index]?.id + const agentSoul = result.data?.agent_soul + if (!nodeId || !agentSoul) return + + const hasMissingFiles = agentSoul.config_files?.some((file) => file.is_missing === true) + const hasMissingSkills = agentSoul.config_skills?.some((skill) => skill.is_missing === true) + if (!hasMissingFiles && !hasMissingSkills) return + + missingReferences[nodeId] = { + hasMissingFiles: !!hasMissingFiles, + hasMissingSkills: !!hasMissingSkills, + } + }) + + return missingReferences + }, + }) const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) const knowledgeBaseEmbeddingProviders = useMemo(() => { @@ -315,6 +378,16 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? if (validationError) errorMessages.push(validationError) } + const missingReferences = inlineAgentMissingReferences[node!.id] + if (missingReferences?.hasMissingFiles) + errorMessages.push( + t(($) => $['agentDetail.configure.files.missing'], { ns: 'agentV2' }), + ) + if (missingReferences?.hasMissingSkills) + errorMessages.push( + t(($) => $['agentDetail.configure.skills.missing'], { ns: 'agentV2' }), + ) + const availableVars = map[node!.id]!.availableVars let hasInvalidVar = false for (const variable of usedVars) { @@ -423,6 +496,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? t, map, modelProviders, + inlineAgentMissingReferences, options?.flowType, ]) diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index 3351b7924e0..68b64d59474 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -46,6 +46,45 @@ describe('agent composer store conversions', () => { ]) }) + it('should preserve missing file and skill references without file ids in autosave config', () => { + const baseConfig = { + config_files: [ + { + file_id: '', + file_kind: 'upload_file', + is_missing: true, + name: 'missing.pdf', + }, + ], + config_skills: [ + { + file_id: '', + file_kind: 'tool_file', + is_missing: true, + name: 'Missing Skill', + }, + ], + } satisfies AgentSoulConfig + const formState = agentSoulConfigToFormState(baseConfig) + + const autosaveConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(autosaveConfig.config_files).toEqual([ + expect.objectContaining({ + file_id: '', + is_missing: true, + name: 'missing.pdf', + }), + ]) + expect(autosaveConfig.config_skills).toEqual([ + expect.objectContaining({ + file_id: '', + is_missing: true, + name: 'Missing Skill', + }), + ]) + }) + it('rebases draft baselines through the composer state action', () => { const store = createStore() const nextDraft = { diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index d7bf769579d..da3a3f02994 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -452,7 +452,7 @@ const toConfigSkillConfigs = ( return skills.flatMap((skill) => { const existing = existingByName.get(skill.name) const fileId = skill.fileId ?? existing?.file_id - if (!fileId) return [] + if (!fileId && !skill.isMissing) return [] return [ { @@ -463,6 +463,7 @@ const toConfigSkillConfigs = ( size: skill.size ?? existing?.size, hash: skill.hash ?? existing?.hash, mime_type: skill.mimeType ?? existing?.mime_type, + ...(skill.isMissing ? { is_missing: true } : {}), }, ] }) @@ -480,7 +481,7 @@ const toConfigFileConfigs = ( const configName = file.configName ?? file.name const existing = existingByName.get(configName) const fileId = file.fileId ?? existing?.file_id - if (!fileId) return [] + if (!fileId && !file.isMissing) return [] return [ { @@ -490,6 +491,7 @@ const toConfigFileConfigs = ( size: file.size ?? existing?.size, hash: file.hash ?? existing?.hash, mime_type: file.mimeType ?? existing?.mime_type, + ...(file.isMissing ? { is_missing: true } : {}), }, ] })