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 8872342821c..e771d4281dc 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -1,5 +1,7 @@ +import type { AgentSoulDifyToolConfig } from '@dify/contracts/api/console/apps/types.gen' import type { CommonNodeType, Node } from '../../types' import type { ChecklistItem } from '../use-checklist' +import type { ToolWithProvider } from '@/app/components/workflow/types' import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen' import { QueryClient } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' @@ -18,6 +20,17 @@ import { useChecklist, useWorkflowRunValidation } from '../use-checklist' // Mocks // --------------------------------------------------------------------------- +const toolServiceState = vi.hoisted(() => ({ + buildInTools: [] as ToolWithProvider[] | undefined, + customTools: [] as ToolWithProvider[] | undefined, + mcpTools: [] as ToolWithProvider[] | undefined, + workflowTools: [] as ToolWithProvider[] | undefined, +})) + +const marketplacePluginState = vi.hoisted(() => ({ + label: undefined as Record | undefined, +})) + vi.mock('reactflow', async () => { const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock() return { @@ -31,9 +44,32 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/service/use-tools', async () => - (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), -) +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: toolServiceState.buildInTools }), + useAllCustomTools: () => ({ data: toolServiceState.customTools }), + useAllMCPTools: () => ({ data: toolServiceState.mcpTools }), + useAllWorkflowTools: () => ({ data: toolServiceState.workflowTools }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({ + data: + infos.length > 0 && marketplacePluginState.label + ? { + data: { + list: infos.map(({ organization, plugin }) => ({ + plugin: { + label: marketplacePluginState.label, + labels: marketplacePluginState.label, + name: plugin, + plugin_id: `${organization}/${plugin}`, + }, + })), + }, + } + : undefined, + }), +})) vi.mock('@/service/use-triggers', async () => (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock(), @@ -163,6 +199,11 @@ beforeEach(() => { Object.keys(mockAvailableVarMap).forEach((k) => delete mockAvailableVarMap[k]) mockModelProviders = [] mockUsedVars = [] + toolServiceState.buildInTools = [] + toolServiceState.customTools = [] + toolServiceState.mcpTools = [] + toolServiceState.workflowTools = [] + marketplacePluginState.label = undefined setupNodesMap() }) @@ -183,11 +224,13 @@ function buildConnectedGraph() { } function buildInlineAgentGraph({ - hasMissingFile, - hasMissingSkill, + difyTools = [], + hasMissingFile = false, + hasMissingSkill = false, }: { - hasMissingFile: boolean - hasMissingSkill: boolean + difyTools?: AgentSoulDifyToolConfig[] + hasMissingFile?: boolean + hasMissingSkill?: boolean }) { const queryClient = new QueryClient({ defaultOptions: { @@ -217,6 +260,9 @@ function buildInlineAgentGraph({ { name: 'Available Skill' }, ...(hasMissingSkill ? [{ is_missing: true, name: 'Missing Skill' }] : []), ], + tools: { + dify_tools: difyTools, + }, }, node_job: {}, save_options: [], @@ -258,6 +304,45 @@ function buildInlineAgentGraph({ } } +const credentialRequiredProvider = { + id: 'google', + name: 'google', + author: 'Google', + description: { + en_US: 'Google tools.', + zh_Hans: 'Google 工具。', + }, + icon: 'https://example.com/google.svg', + icon_dark: 'https://example.com/google-dark.svg', + label: { + en_US: 'Google Tools', + zh_Hans: 'Google 工具', + }, + type: CollectionType.builtIn, + team_credentials: { + api_key: { + label: { + en_US: 'API Key', + zh_Hans: 'API Key', + }, + placeholder: { + en_US: 'Enter API key', + zh_Hans: '输入 API Key', + }, + required: true, + type: 'secret-input', + variable: 'api_key', + }, + }, + is_team_authorization: false, + allow_delete: false, + labels: [], + meta: { + version: '0.0.1', + }, + tools: [], +} satisfies ToolWithProvider + // --------------------------------------------------------------------------- // useChecklist // --------------------------------------------------------------------------- @@ -343,6 +428,61 @@ describe('useChecklist', () => { expect(result.current).toEqual([]) }) + it('should report uninstalled tools from inline agents and open their configuration panel', async () => { + marketplacePluginState.label = { + en_US: 'Jina', + } + const { edges, nodeId, nodes, options } = buildInlineAgentGraph({ + difyTools: [ + { + credential_type: 'unauthorized', + plugin_id: 'langgenius/jina_tool', + provider: 'langgenius/jina_tool/jina', + provider_id: 'langgenius/jina_tool/jina', + provider_type: 'plugin', + tool_name: 'search', + }, + ], + }) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + await waitFor(() => { + expect(result.current).toEqual([ + expect.objectContaining({ + id: nodeId, + errorMessages: ['workflow.nodes.agent.toolNotInstallTooltip:{"tool":"Jina"}'], + openInlineAgentPanel: true, + }), + ]) + }) + }) + + it('should report unauthorized tools from inline agents and open their configuration panel', async () => { + toolServiceState.buildInTools = [credentialRequiredProvider] + const { edges, nodeId, nodes, options } = buildInlineAgentGraph({ + difyTools: [ + { + credential_type: 'unauthorized', + provider: 'google', + provider_id: 'google', + provider_type: 'builtin', + tool_name: 'search', + }, + ], + }) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + await waitFor(() => { + expect(result.current).toEqual([ + expect.objectContaining({ + id: nodeId, + errorMessages: ['workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"Google Tools"}'], + openInlineAgentPanel: true, + }), + ]) + }) + }) + 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 944c2e5ebd7..3ef5178a350 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -15,6 +15,7 @@ import type { } from '../types' import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Emoji } from '@/app/components/tools/types' +import type { AgentToolPublishIssue } from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog' import type { DataSet } from '@/models/datasets' import type { I18nKeysWithPrefix } from '@/types/i18n' import { toast } from '@langgenius/dify-ui/toast' @@ -31,6 +32,12 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { MAX_TREE_DEPTH } from '@/config' import { useGetLanguage } from '@/context/i18n' import { useProviderContextSelector } from '@/context/provider-context' +import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions' +import { + createAgentToolProviderCatalog, + getAgentToolPublishIssues, + useAgentToolPresentation, +} from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog' import { consoleQuery } from '@/service/client' import { fetchDatasets } from '@/service/datasets' import { useStrategyProviders } from '@/service/use-strategy' @@ -148,6 +155,10 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const inlineAgentToolProviderCatalog = useMemo( + () => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }), + [buildInTools, customTools, mcpTools, workflowTools], + ) const dataSourceList = useStore((s) => s.dataSourceList) const environmentVariables = useStore((s) => s.environmentVariables) ?? EMPTY_ENVIRONMENT_VARIABLES @@ -173,7 +184,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? ), [nodes], ) - const inlineAgentMissingReferences = useQueries({ + const inlineAgentConfigurationIssues = useQueries({ queries: !configsMap?.flowId || (configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet) @@ -202,9 +213,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? ), ), combine: (results) => { - const missingReferences: Record< + const issuesByNodeId: Record< string, - { hasMissingFiles: boolean; hasMissingSkills: boolean } + { + hasMissingFiles: boolean + hasMissingSkills: boolean + toolIssues: AgentToolPublishIssue[] + } > = {} results.forEach((result, index) => { @@ -214,17 +229,33 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? 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 + const toolIssues = getAgentToolPublishIssues( + agentSoulConfigToFormState(agentSoul).tools, + inlineAgentToolProviderCatalog, + ) + if (!hasMissingFiles && !hasMissingSkills && toolIssues.length === 0) return - missingReferences[nodeId] = { + issuesByNodeId[nodeId] = { hasMissingFiles: !!hasMissingFiles, hasMissingSkills: !!hasMissingSkills, + toolIssues, } }) - return missingReferences + return issuesByNodeId }, }) + const inlineAgentIssueTools = useMemo( + () => + Object.values(inlineAgentConfigurationIssues).flatMap((issues) => + issues.toolIssues.map((issue) => issue.tool), + ), + [inlineAgentConfigurationIssues], + ) + const inlineAgentToolPresentation = useAgentToolPresentation( + inlineAgentIssueTools, + inlineAgentToolProviderCatalog, + ) const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) const knowledgeBaseEmbeddingProviders = useMemo(() => { @@ -366,7 +397,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? }) const errorMessages: string[] = [] - const missingReferences = inlineAgentMissingReferences[node!.id] + const inlineAgentIssues = inlineAgentConfigurationIssues[node!.id] if (isPluginMissing) { errorMessages.push(t(($) => $['nodes.common.pluginNotInstalled'], { ns: 'workflow' })) @@ -398,14 +429,30 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? if (validationError) errorMessages.push(validationError) } - if (missingReferences?.hasMissingFiles) + if (inlineAgentIssues?.hasMissingFiles) errorMessages.push( t(($) => $['agentDetail.configure.files.missing'], { ns: 'agentV2' }), ) - if (missingReferences?.hasMissingSkills) + if (inlineAgentIssues?.hasMissingSkills) errorMessages.push( t(($) => $['agentDetail.configure.skills.missing'], { ns: 'agentV2' }), ) + for (const toolIssue of inlineAgentIssues?.toolIssues ?? []) { + const toolName = + inlineAgentToolPresentation.toolDisplayNameById.get(toolIssue.tool.id) ?? + toolIssue.tool.name + errorMessages.push( + toolIssue.type === 'uninstalled' + ? t(($) => $['nodes.agent.toolNotInstallTooltip'], { + ns: 'workflow', + tool: toolName, + }) + : t(($) => $['nodes.agent.toolNotAuthorizedTooltip'], { + ns: 'workflow', + tool: toolName, + }), + ) + } const availableVars = map[node!.id]!.availableVars let hasInvalidVar = false @@ -447,7 +494,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? pluginUniqueIdentifier: isPluginMissing ? (node!.data as { plugin_unique_identifier?: string }).plugin_unique_identifier : undefined, - ...(missingReferences ? { openInlineAgentPanel: true } : {}), + ...(inlineAgentIssues ? { openInlineAgentPanel: true } : {}), }) } } @@ -517,7 +564,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? t, map, modelProviders, - inlineAgentMissingReferences, + inlineAgentConfigurationIssues, + inlineAgentToolPresentation.toolDisplayNameById, options?.flowType, ]) diff --git a/web/features/agent-v2/agent-composer/store-modules/tools.ts b/web/features/agent-v2/agent-composer/store-modules/tools.ts index eaeeac348d3..4e4370c8610 100644 --- a/web/features/agent-v2/agent-composer/store-modules/tools.ts +++ b/web/features/agent-v2/agent-composer/store-modules/tools.ts @@ -7,7 +7,9 @@ import type { } from '../form-state' import type { DraftFieldUpdate } from './utils' import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import isEqual from 'fast-deep-equal' import { atom } from 'jotai' +import { selectAtom } from 'jotai/utils' import { syncCliToolReferenceLabels } from '../reference-labels' import { agentComposerDraftAtom } from '../store' import { resolveDraftFieldUpdate } from './utils' @@ -37,6 +39,26 @@ export const agentComposerToolsAtom = atom( }, ) +export const agentComposerToolPresentationIdentitiesAtom = selectAtom( + agentComposerToolsAtom, + (tools) => + tools.flatMap((tool) => { + if (tool.kind !== 'provider') return [] + + return [ + { + kind: tool.kind, + id: tool.id, + name: tool.name, + displayName: tool.displayName, + pluginId: tool.pluginId, + providerType: tool.providerType, + }, + ] + }), + isEqual, +) + const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({ id: `${tool.provider_id}:${tool.tool_name}`, name: tool.tool_label || tool.title || tool.tool_name, diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index bb44c924166..7605ff25ec6 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -1,7 +1,10 @@ import type { PropsWithChildren } from 'react' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' +import { CollectionType } from '@/app/components/tools/types' import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { @@ -20,6 +23,17 @@ const toastMock = vi.hoisted(() => ({ const trackEventMock = vi.hoisted(() => vi.fn()) +const toolProviderState = vi.hoisted(() => ({ + builtInTools: [] as ToolWithProvider[] | undefined, + customTools: [] as ToolWithProvider[] | undefined, + mcpTools: [] as ToolWithProvider[] | undefined, + workflowTools: [] as ToolWithProvider[] | undefined, +})) + +const marketplacePluginState = vi.hoisted(() => ({ + label: undefined as Record | undefined, +})) + const composerPutMutationFn = vi.hoisted(() => vi.fn( async (variables: { @@ -139,6 +153,37 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: trackEventMock, })) +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({ + data: + infos.length > 0 && marketplacePluginState.label + ? { + data: { + list: infos.map(({ organization, plugin }) => ({ + plugin: { + label: marketplacePluginState.label, + labels: marketplacePluginState.label, + name: plugin, + plugin_id: `${organization}/${plugin}`, + }, + })), + }, + } + : undefined, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: toolProviderState.builtInTools }), + useAllCustomTools: () => ({ data: toolProviderState.customTools }), + useAllMCPTools: () => ({ data: toolProviderState.mcpTools }), + useAllWorkflowTools: () => ({ data: toolProviderState.workflowTools }), +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -219,11 +264,55 @@ function renderUseAgentConfigureSync({ } } +const credentialRequiredProvider = { + id: 'google', + name: 'google', + author: 'Google', + description: { + en_US: 'Google tools.', + zh_Hans: 'Google 工具。', + }, + icon: 'https://example.com/google.svg', + icon_dark: 'https://example.com/google-dark.svg', + label: { + en_US: 'Google Tools', + zh_Hans: 'Google 工具', + }, + type: CollectionType.builtIn, + team_credentials: { + api_key: { + label: { + en_US: 'API Key', + zh_Hans: 'API Key', + }, + placeholder: { + en_US: 'Enter API key', + zh_Hans: '输入 API Key', + }, + required: true, + type: 'secret-input', + variable: 'api_key', + }, + }, + is_team_authorization: false, + allow_delete: false, + labels: [], + meta: { + version: '0.0.1', + }, + tools: [], +} satisfies ToolWithProvider + describe('useAgentConfigureSync', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() composerPutRequestContexts.length = 0 + toolProviderState.builtInTools = [] + toolProviderState.customTools = [] + toolProviderState.mcpTools = [] + toolProviderState.workflowTools = [] + marketplacePluginState.label = undefined }) afterEach(() => { @@ -880,6 +969,81 @@ describe('useAgentConfigureSync', () => { expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') }) + it('should toast and skip publish when a configured tool is not installed', async () => { + marketplacePluginState.label = { + en_US: 'Jina', + } + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'langgenius/jina_tool/jina', + kind: 'provider', + name: 'langgenius/jina_tool/jina', + iconClassName: 'i-custom-public-other-default-tool-icon', + providerType: 'plugin', + credentialType: 'unauthorized', + credentialVariant: 'unauthorized', + actions: [], + }, + ], + } satisfies AgentSoulConfigFormState) + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(trackEventMock).not.toHaveBeenCalled() + expect(toastMock.error).toHaveBeenCalledWith( + 'workflow.nodes.agent.toolNotInstallTooltip:{"tool":"Jina"}', + ) + }) + + it('should toast and skip publish when a configured tool is not authorized', async () => { + toolProviderState.builtInTools = [credentialRequiredProvider] + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'google', + kind: 'provider', + name: 'google', + displayName: 'Google Tools', + iconClassName: 'i-custom-public-other-default-tool-icon', + providerType: 'builtin', + credentialType: 'unauthorized', + credentialVariant: 'unauthorized', + actions: [], + }, + ], + } satisfies AgentSoulConfigFormState) + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(trackEventMock).not.toHaveBeenCalled() + expect(toastMock.error).toHaveBeenCalledWith( + 'workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"Google Tools"}', + ) + }) + it('should keep default model fallback from leaving the local draft dirty after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx index 27f97479eda..e7bfb137239 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx @@ -1,6 +1,5 @@ 'use client' -import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { ToolSettingTarget } from './types' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' @@ -18,27 +17,25 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { parseToolProviderType } from '@/app/components/tools/provider-type' -import { CollectionType } from '@/app/components/tools/types' import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker' -import { useGetLanguage } from '@/context/i18n' import { addProviderToolsAtom, agentComposerToolsAtom, setProviderToolCredentialAtom, } from '@/features/agent-v2/agent-composer/store-modules/tools' import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags' -import { - useFetchPluginsInMarketPlaceByInfo, - useInvalidateInstalledPluginList, -} from '@/service/use-plugins' -import { - useAllBuiltInTools, - useAllCustomTools, - useAllMCPTools, - useAllWorkflowTools, - useInvalidateAllBuiltInTools, -} from '@/service/use-tools' +import { useInvalidateInstalledPluginList } from '@/service/use-plugins' +import { useInvalidateAllBuiltInTools } from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' +import { + getAgentProviderPluginId, + getAgentProviderToolDisplayName, + getLocalizedText, + getProviderCredentialType, + getProviderCredentialVariant, + useAgentToolPresentation, + useAgentToolProviderCatalog, +} from '../../../tool-provider-catalog' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { ConfigureSectionEmpty } from '../common/empty' @@ -135,92 +132,6 @@ const AgentToolItem = memo( }, ) -function useAgentToolProviderMap() { - const { data: buildInTools } = useAllBuiltInTools() - const { data: customTools } = useAllCustomTools() - const { data: workflowTools } = useAllWorkflowTools() - const { data: mcpTools } = useAllMCPTools() - - return useMemo(() => { - const providers = new Map() - const resolvedProviderTypes = new Set() - const buildInToolList = Array.isArray(buildInTools) ? buildInTools : [] - const customToolList = Array.isArray(customTools) ? customTools : [] - const workflowToolList = Array.isArray(workflowTools) ? workflowTools : [] - const mcpToolList = Array.isArray(mcpTools) ? mcpTools : [] - const allProviders = [ - ...buildInToolList, - ...customToolList, - ...workflowToolList, - ...mcpToolList, - ] - - if (Array.isArray(buildInTools)) { - resolvedProviderTypes.add(CollectionType.builtIn) - resolvedProviderTypes.add('plugin') - } - if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom) - if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow) - if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp) - - allProviders.forEach((provider) => { - providers.set(provider.id, provider) - providers.set(provider.name, provider) - if (provider.plugin_id) { - providers.set(provider.plugin_id, provider) - providers.set(`${provider.plugin_id}/${provider.name}`, provider) - } - }) - - return { - providerById: providers, - resolvedProviderTypes, - } - }, [buildInTools, customTools, workflowTools, mcpTools]) -} - -function getLocalizedText(text: Partial> | undefined, language: string) { - return text?.[language] ?? text?.en_US ?? text?.zh_Hans -} - -function getProviderPluginId(tool: AgentProviderTool) { - if (tool.pluginId) return tool.pluginId - - if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return '' - - const providerIdSegments = tool.id.split('/') - if (providerIdSegments.length !== 3) return '' - - return providerIdSegments.slice(0, 2).join('/') -} - -function getProviderDisplayName(tool: AgentProviderTool) { - const providerIdSegments = tool.name.split('/').filter(Boolean) - return providerIdSegments.at(-1) ?? tool.name -} - -function getMarketplacePluginInfo(pluginId: string) { - const [organization, plugin, ...remainingSegments] = pluginId.split('/') - if (!organization || !plugin || remainingSegments.length > 0) return undefined - - return { - organization, - plugin, - } -} - -function getProviderCredentialType( - provider?: ToolWithProvider, -): AgentProviderTool['credentialType'] { - if (!provider) return undefined - - if (Object.keys(provider.team_credentials ?? {}).length > 0) return 'api-key' - - if (provider.type === CollectionType.builtIn && provider.allow_delete) return 'oauth2' - - return undefined -} - function getDisplayCredentialType( tool: AgentProviderTool, providerCredentialType: AgentProviderTool['credentialType'], @@ -233,27 +144,13 @@ function getDisplayCredentialType( return tool.credentialType ?? providerCredentialType } -function getProviderCredentialVariant( - tool: AgentProviderTool, - provider: ToolWithProvider, - providerCredentialType: AgentProviderTool['credentialType'], -) { - if (!providerCredentialType) return 'none' as const - - if (tool.credentialVariant !== 'none') return tool.credentialVariant - - return tool.credentialId || provider.is_team_authorization - ? ('authorized' as const) - : ('unauthorized' as const) -} - function useDisplayTools( tools: AgentTool[], providerById: Map, resolvedProviderTypes: Set, - marketplacePluginById: Map, + toolPresentation: ReturnType, ) { - const language = useGetLanguage() + const { language, marketplacePluginById } = toolPresentation return useMemo(() => { return tools.map((tool): DisplayAgentTool => { @@ -262,7 +159,7 @@ function useDisplayTools( const provider = providerById.get(tool.id) ?? providerById.get(tool.name) if (!provider) { - const providerPluginId = getProviderPluginId(tool) + const providerPluginId = getAgentProviderPluginId(tool) const marketplacePlugin = marketplacePluginById.get(providerPluginId) return { @@ -271,11 +168,11 @@ function useDisplayTools( pluginId: tool.pluginId ?? providerPluginId, pluginUniqueIdentifier: tool.pluginUniqueIdentifier ?? marketplacePlugin?.latest_package_identifier, - displayName: - tool.displayName ?? - getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ?? - marketplacePlugin?.name ?? - getProviderDisplayName(tool), + displayName: getAgentProviderToolDisplayName({ + language, + marketplacePlugin, + tool, + }), icon: tool.icon ?? (marketplacePlugin && providerPluginId @@ -292,7 +189,7 @@ function useDisplayTools( return { ...tool, isInstalled: true, - displayName: tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name, + displayName: getAgentProviderToolDisplayName({ language, provider, tool }), icon: tool.icon ?? provider.icon, iconDark: tool.iconDark ?? provider.icon_dark, providerType: tool.providerType, @@ -378,7 +275,7 @@ function AddToolMenu({ const { t } = useTranslation('agentV2') const [open, setOpen] = useState(false) const [view, setView] = useState(addToolDefaultView) - const { providerById } = useAgentToolProviderMap() + const { providerById } = useAgentToolProviderCatalog() const openToolPicker = useCallback(() => { setView('tool-picker') @@ -487,7 +384,7 @@ export function AgentTools() { const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom) const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - const { providerById, resolvedProviderTypes } = useAgentToolProviderMap() + const { providerById, resolvedProviderTypes } = useAgentToolProviderCatalog() const tools = useAtomValue(agentComposerToolsAtom) const selectedTools = useSelectedProviderTools() const addTools = useSetAtom(addProviderToolsAtom) @@ -527,45 +424,15 @@ export function AgentTools() { () => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')), [tools], ) - const missingMarketplacePluginInfos = useMemo(() => { - const pluginIds = new Set() - - visibleTools.forEach((tool) => { - if ( - tool.kind !== 'provider' || - !resolvedProviderTypes.has(tool.providerType) || - providerById.has(tool.id) || - providerById.has(tool.name) - ) - return - - const pluginId = getProviderPluginId(tool) - if (pluginId) pluginIds.add(pluginId) - }) - - return Array.from(pluginIds).flatMap((pluginId) => { - const info = getMarketplacePluginInfo(pluginId) - return info ? [info] : [] - }) - }, [providerById, resolvedProviderTypes, visibleTools]) - const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo( - missingMarketplacePluginInfos, - ) - const marketplacePluginById = useMemo( - () => - new Map( - (missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [ - plugin.plugin_id, - plugin, - ]), - ), - [missingMarketplacePluginsData], - ) + const toolPresentation = useAgentToolPresentation(visibleTools, { + providerById, + resolvedProviderTypes, + }) const displayTools = useDisplayTools( visibleTools, providerById, resolvedProviderTypes, - marketplacePluginById, + toolPresentation, ) /* * knip-ignore-start diff --git a/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts b/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts new file mode 100644 index 00000000000..4672541f139 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/tool-provider-catalog.ts @@ -0,0 +1,267 @@ +'use client' + +import type { MarketplacePlugin } from '@dify/contracts/marketplace' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import type { AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' +import { useMemo } from 'react' +import { CollectionType } from '@/app/components/tools/types' +import { useGetLanguage } from '@/context/i18n' +import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' + +type AgentToolPresentationProvider = Pick< + AgentProviderTool, + 'kind' | 'id' | 'name' | 'displayName' | 'pluginId' | 'providerType' +> +type AgentToolPresentationSource = AgentTool | AgentToolPresentationProvider + +export type AgentToolProviderCatalog = { + providerById: Map + resolvedProviderTypes: Set +} + +export function createAgentToolProviderCatalog({ + buildInTools, + customTools, + mcpTools, + workflowTools, +}: { + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] +}): AgentToolProviderCatalog { + const providers = new Map() + const resolvedProviderTypes = new Set() + const buildInToolList = Array.isArray(buildInTools) ? buildInTools : [] + const customToolList = Array.isArray(customTools) ? customTools : [] + const workflowToolList = Array.isArray(workflowTools) ? workflowTools : [] + const mcpToolList = Array.isArray(mcpTools) ? mcpTools : [] + const allProviders = [...buildInToolList, ...customToolList, ...workflowToolList, ...mcpToolList] + + if (Array.isArray(buildInTools)) { + resolvedProviderTypes.add(CollectionType.builtIn) + resolvedProviderTypes.add('plugin') + } + if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom) + if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow) + if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp) + + allProviders.forEach((provider) => { + providers.set(provider.id, provider) + providers.set(provider.name, provider) + if (provider.plugin_id) { + providers.set(provider.plugin_id, provider) + providers.set(`${provider.plugin_id}/${provider.name}`, provider) + } + }) + + return { + providerById: providers, + resolvedProviderTypes, + } +} + +export function useAgentToolProviderCatalog(): AgentToolProviderCatalog { + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + + return useMemo( + () => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }), + [buildInTools, customTools, mcpTools, workflowTools], + ) +} + +export function getLocalizedText( + text: Partial> | undefined, + language: string, +) { + return text?.[language] ?? text?.en_US ?? text?.zh_Hans +} + +export function getAgentProviderPluginId(tool: AgentToolPresentationProvider) { + if (tool.pluginId) return tool.pluginId + + if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return '' + + const providerIdSegments = tool.id.split('/') + if (providerIdSegments.length !== 3) return '' + + return providerIdSegments.slice(0, 2).join('/') +} + +function getMarketplacePluginInfo(pluginId: string) { + const [organization, plugin, ...remainingSegments] = pluginId.split('/') + if (!organization || !plugin || remainingSegments.length > 0) return undefined + + return { + organization, + plugin, + } +} + +function getProviderFallbackDisplayName(tool: AgentToolPresentationProvider) { + const providerIdSegments = tool.name.split('/').filter(Boolean) + return providerIdSegments.at(-1) ?? tool.name +} + +export function getAgentProviderToolDisplayName({ + language, + marketplacePlugin, + provider, + tool, +}: { + language: string + marketplacePlugin?: MarketplacePlugin + provider?: ToolWithProvider + tool: AgentToolPresentationProvider +}) { + if (provider) return tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name + + return ( + tool.displayName ?? + getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ?? + marketplacePlugin?.name ?? + getProviderFallbackDisplayName(tool) + ) +} + +export function useAgentToolPresentation( + tools: AgentToolPresentationSource[], + { providerById, resolvedProviderTypes }: AgentToolProviderCatalog, +) { + const language = useGetLanguage() + const missingMarketplacePluginInfos = useMemo(() => { + const pluginIds = new Set() + + tools.forEach((tool) => { + if ( + tool.kind !== 'provider' || + !resolvedProviderTypes.has(tool.providerType) || + providerById.has(tool.id) || + providerById.has(tool.name) + ) + return + + const pluginId = getAgentProviderPluginId(tool) + if (pluginId) pluginIds.add(pluginId) + }) + + return Array.from(pluginIds).flatMap((pluginId) => { + const info = getMarketplacePluginInfo(pluginId) + return info ? [info] : [] + }) + }, [providerById, resolvedProviderTypes, tools]) + const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo( + missingMarketplacePluginInfos, + ) + const marketplacePluginById = useMemo( + () => + new Map( + (missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [ + plugin.plugin_id, + plugin, + ]), + ), + [missingMarketplacePluginsData], + ) + const toolDisplayNameById = useMemo(() => { + const displayNames = new Map() + + tools.forEach((tool) => { + if (tool.kind !== 'provider') return + + const provider = providerById.get(tool.id) ?? providerById.get(tool.name) + const marketplacePlugin = marketplacePluginById.get(getAgentProviderPluginId(tool)) + displayNames.set( + tool.id, + getAgentProviderToolDisplayName({ language, marketplacePlugin, provider, tool }), + ) + }) + + return displayNames + }, [language, marketplacePluginById, providerById, tools]) + + return { + language, + marketplacePluginById, + toolDisplayNameById, + } +} + +export function getProviderCredentialType( + provider?: ToolWithProvider, +): AgentProviderTool['credentialType'] { + if (!provider) return undefined + + if (Object.keys(provider.team_credentials ?? {}).length > 0) return 'api-key' + + if (provider.type === CollectionType.builtIn && provider.allow_delete) return 'oauth2' + + return undefined +} + +export function getProviderCredentialVariant( + tool: AgentProviderTool, + provider: ToolWithProvider, + providerCredentialType: AgentProviderTool['credentialType'], +) { + if (!providerCredentialType) return 'none' as const + + if (tool.credentialVariant !== 'none') return tool.credentialVariant + + return tool.credentialId || provider.is_team_authorization + ? ('authorized' as const) + : ('unauthorized' as const) +} + +export type AgentToolPublishIssue = { + type: 'uninstalled' | 'unauthorized' + tool: AgentProviderTool +} + +export function getAgentToolPublishIssues( + tools: AgentTool[], + { providerById, resolvedProviderTypes }: AgentToolProviderCatalog, +): AgentToolPublishIssue[] { + const issues: AgentToolPublishIssue[] = [] + + for (const tool of tools) { + if (tool.kind !== 'provider') continue + + const provider = providerById.get(tool.id) ?? providerById.get(tool.name) + if (!provider) { + if (resolvedProviderTypes.has(tool.providerType)) { + issues.push({ + type: 'uninstalled', + tool, + }) + } + continue + } + + const providerCredentialType = getProviderCredentialType(provider) + if (getProviderCredentialVariant(tool, provider, providerCredentialType) === 'unauthorized') { + issues.push({ + type: 'unauthorized', + tool, + }) + } + } + + return issues +} + +export function getAgentToolPublishIssue( + tools: AgentTool[], + catalog: AgentToolProviderCatalog, +): AgentToolPublishIssue | undefined { + return getAgentToolPublishIssues(tools, catalog)[0] +} diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index 0148668c358..b8dfde59f30 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import isEqual from 'fast-deep-equal' -import { useSetAtom, useStore } from 'jotai' +import { useAtomValue, useSetAtom, useStore } from 'jotai' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' @@ -22,7 +22,13 @@ import { agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' +import { agentComposerToolPresentationIdentitiesAtom } from '@/features/agent-v2/agent-composer/store-modules/tools' import { consoleQuery } from '@/service/client' +import { + getAgentToolPublishIssue, + useAgentToolPresentation, + useAgentToolProviderCatalog, +} from './tool-provider-catalog' const DRAFT_AUTOSAVE_WAIT = 5000 @@ -40,7 +46,11 @@ export function useAgentConfigureSync({ enabled: boolean }) { const { t: tCommon } = useTranslation('common') + const { t: tWorkflow } = useTranslation('workflow') const getKnowledgeValidationMessage = useKnowledgeValidationMessage() + const toolPresentationIdentities = useAtomValue(agentComposerToolPresentationIdentitiesAtom) + const toolProviderCatalog = useAgentToolProviderCatalog() + const toolPresentation = useAgentToolPresentation(toolPresentationIdentities, toolProviderCatalog) const queryClient = useQueryClient() const store = useStore() const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom) @@ -355,6 +365,19 @@ export function useAgentConfigureSync({ return } + const toolPublishIssue = getAgentToolPublishIssue(draft.tools, toolProviderCatalog) + if (toolPublishIssue) { + const toolName = + toolPresentation.toolDisplayNameById.get(toolPublishIssue.tool.id) ?? + toolPublishIssue.tool.name + toast.error( + toolPublishIssue.type === 'uninstalled' + ? tWorkflow(($) => $['nodes.agent.toolNotInstallTooltip'], { tool: toolName }) + : tWorkflow(($) => $['nodes.agent.toolNotAuthorizedTooltip'], { tool: toolName }), + ) + return + } + const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals) if (!knowledgeValidation.isValid) { toast.error( @@ -406,6 +429,9 @@ export function useAgentConfigureSync({ runPublishTransaction, store, tCommon, + toolProviderCatalog, + toolPresentation.toolDisplayNameById, + tWorkflow, ]) return { diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index e9da1b02cd2..96038d3c88e 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -472,7 +472,7 @@ "nodes.agent.task.mention": "Mention", "nodes.agent.task.placeholder": "Describe what this agent should do...", "nodes.agent.task.tooltip": "Additional prompt to help agent handle this very node. Use / to make explicit reference to variables.\nIf configured properly, you could be able to trust your agent to figure things out itself.", - "nodes.agent.toolNotAuthorizedTooltip": "{{tool}} Not Authorized", + "nodes.agent.toolNotAuthorizedTooltip": "{{tool}} is not authorized", "nodes.agent.toolNotInstallTooltip": "{{tool}} is not installed", "nodes.agent.toolbox": "toolbox", "nodes.agent.tools": "Tools",