From ae0b66311d789dd9341edf6f3e0aaf9fde7249ae Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 16:23:42 +0800 Subject: [PATCH] feat: add missing plugin installation to agent DSL imports (#39680) --- .../agents/__tests__/layout.spec.tsx | 39 ++++ web/app/(commonLayout)/agents/layout.tsx | 8 +- .../workflow/plugin-dependency/index.tsx | 2 + .../agent-composer/__tests__/store.spec.ts | 34 ++++ .../agent-v2/agent-composer/conversions.ts | 21 ++- .../agent-v2/agent-composer/form-state.ts | 2 + .../agent-composer/store-modules/tools.ts | 5 + .../tools/__tests__/index.spec.tsx | 167 +++++++++++++++++- .../components/orchestrate/tools/index.tsx | 149 ++++++++++++++-- .../orchestrate/tools/provider-tool/item.tsx | 111 +++++++++--- 10 files changed, 495 insertions(+), 43 deletions(-) diff --git a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx index 8bcf8a44b5b..edb7035db74 100644 --- a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx @@ -1,10 +1,20 @@ import type { ReactNode } from 'react' +import type { Dependency } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' +import { useStore as usePluginDependencyStore } from '@/app/components/workflow/plugin-dependency/store' const mocks = vi.hoisted(() => ({ guardAgentV2Route: vi.fn(), })) +vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({ + default: ({ fromDSLPayload }: { fromDSLPayload: Dependency[] }) => ( +
+ {`bundle-size:${fromDSLPayload.length}`} +
+ ), +})) + vi.mock('../feature-guard', () => ({ guardAgentV2Route: () => mocks.guardAgentV2Route(), })) @@ -18,6 +28,7 @@ vi.mock('../agents-access-guard', () => ({ describe('RosterLayout', () => { beforeEach(() => { vi.clearAllMocks() + usePluginDependencyStore.setState({ dependencies: [] }) }) it('should render children when Agent v2 is enabled', async () => { @@ -33,6 +44,34 @@ describe('RosterLayout', () => { expect(screen.getByText('Roster content')).toBeInTheDocument() }) + it('should show the missing-plugin installer across Agent routes', async () => { + usePluginDependencyStore.setState({ + dependencies: [ + { + type: 'marketplace', + value: { + organization: 'langgenius', + plugin: 'sample-plugin', + version: '1.0.0', + plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0', + }, + }, + ], + }) + const { default: RosterLayout } = await import('../layout') + + render( + +
Agent route content
+
, + ) + + expect(screen.getByRole('dialog', { name: 'Install missing plugins' })).toHaveTextContent( + 'bundle-size:1', + ) + expect(screen.getByText('Agent route content')).toBeInTheDocument() + }) + it('should block rendering when the roster guard throws notFound', async () => { mocks.guardAgentV2Route.mockImplementation(() => { throw new Error('NEXT_NOT_FOUND') diff --git a/web/app/(commonLayout)/agents/layout.tsx b/web/app/(commonLayout)/agents/layout.tsx index 5f87a857cb1..ec47488a564 100644 --- a/web/app/(commonLayout)/agents/layout.tsx +++ b/web/app/(commonLayout)/agents/layout.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from 'react' +import PluginDependency from '@/app/components/workflow/plugin-dependency' import { AgentsAccessGuard } from './agents-access-guard' import { guardAgentV2Route } from './feature-guard' export default function Layout({ children }: { children: ReactNode }) { guardAgentV2Route() - return {children} + return ( + + + {children} + + ) } diff --git a/web/app/components/workflow/plugin-dependency/index.tsx b/web/app/components/workflow/plugin-dependency/index.tsx index 1630d516c65..6fe95e0a321 100644 --- a/web/app/components/workflow/plugin-dependency/index.tsx +++ b/web/app/components/workflow/plugin-dependency/index.tsx @@ -1,3 +1,5 @@ +'use client' + import { useCallback } from 'react' import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle' import { useStore } from './store' 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 ed7615457aa..c4dd9d3dab1 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -381,6 +381,40 @@ describe('agent composer store conversions', () => { }) }) + it('should preserve a plugin tool identity when hydrating and publishing imported config', () => { + const baseConfig = { + tools: { + dify_tools: [ + { + plugin_id: 'langgenius/google', + provider_id: 'langgenius/google/google', + provider_type: 'plugin', + tool_name: 'search', + credential_type: 'unauthorized', + }, + ], + }, + } satisfies AgentSoulConfig + + const formState = agentSoulConfigToFormState(baseConfig) + const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(formState.tools).toEqual([ + expect.objectContaining({ + id: 'langgenius/google/google', + name: 'google', + pluginId: 'langgenius/google', + }), + ]) + expect(publishConfig.tools?.dify_tools).toEqual([ + expect.objectContaining({ + plugin_id: 'langgenius/google', + provider: 'google', + provider_id: 'langgenius/google/google', + }), + ]) + }) + it('should hydrate legacy secret refs from ref when value is absent', () => { const formState = agentSoulConfigToFormState({ env: { diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index 4b79f162fce..abc2b698769 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -197,8 +197,21 @@ const toToolRuntimeParameters = (settings: Record | undefined) return runtimeParameters } +const getDifyToolProviderId = (tool: AgentSoulDifyToolConfig) => + tool.provider_id ?? + (tool.plugin_id && tool.provider + ? `${tool.plugin_id}/${tool.provider}` + : (tool.provider ?? tool.plugin_id ?? '')) + +const getDifyToolProviderName = (tool: AgentSoulDifyToolConfig) => { + if (tool.provider) return tool.provider + + const providerIdSegments = getDifyToolProviderId(tool).split('/').filter(Boolean) + return providerIdSegments.at(-1) ?? '' +} + const getDifyToolActionId = (tool: AgentSoulDifyToolConfig) => - `${tool.provider_id ?? tool.provider ?? tool.plugin_id ?? 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` + `${getDifyToolProviderId(tool) || 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` const toCredentialVariant = (tool: AgentSoulDifyToolConfig) => { const credentialType = tool.credential_type @@ -227,7 +240,7 @@ const toProviderToolFormState = ( const toolSettings: AgentSoulConfigFormState['toolSettings'] = {} for (const tool of config?.tools?.dify_tools ?? []) { - const providerId = tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '' + const providerId = getDifyToolProviderId(tool) const toolName = tool.tool_name ?? tool.name ?? '' if (!providerId || !toolName) continue @@ -249,8 +262,9 @@ const toProviderToolFormState = ( toolByProviderId.set(providerId, { id: providerId, - name: tool.provider ?? providerId, + name: getDifyToolProviderName(tool), kind: 'provider', + pluginId: tool.plugin_id ?? undefined, iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', providerType: tool.provider_type, allowDelete: @@ -287,6 +301,7 @@ const toDifyToolConfigs = ( enabled: true, provider: tool.name, provider_id: tool.id, + plugin_id: tool.pluginId, provider_type: tool.providerType, tool_name: action.toolName, runtime_parameters: toToolRuntimeParameters(toolSettings[action.id]), diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts index 76c6e7f3fd5..ad93897b302 100644 --- a/web/features/agent-v2/agent-composer/form-state.ts +++ b/web/features/agent-v2/agent-composer/form-state.ts @@ -93,6 +93,8 @@ type AgentProviderToolCredentialType = 'api-key' | 'oauth2' | 'unauthorized' export type AgentProviderTool = AgentToolBase & { kind: 'provider' displayName?: string + pluginId?: string + pluginUniqueIdentifier?: string iconClassName: string icon?: ToolDefaultValue['provider_icon'] iconDark?: ToolDefaultValue['provider_icon_dark'] 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 9cbc98b3722..eaeeac348d3 100644 --- a/web/features/agent-v2/agent-composer/store-modules/tools.ts +++ b/web/features/agent-v2/agent-composer/store-modules/tools.ts @@ -88,6 +88,9 @@ export const addProviderTools = ( nextTools[existingToolIndex] = { ...existingTool, displayName: existingTool.displayName ?? selectedTool.provider_show_name, + pluginId: existingTool.pluginId ?? selectedTool.plugin_id, + pluginUniqueIdentifier: + existingTool.pluginUniqueIdentifier ?? selectedTool.plugin_unique_identifier, icon: existingTool.icon ?? selectedTool.provider_icon, iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark, allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete, @@ -101,6 +104,8 @@ export const addProviderTools = ( name: selectedTool.provider_name, kind: 'provider', displayName: selectedTool.provider_show_name, + pluginId: selectedTool.plugin_id, + pluginUniqueIdentifier: selectedTool.plugin_unique_identifier, iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', icon: selectedTool.provider_icon, iconDark: selectedTool.provider_icon_dark, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 6419ea4c7c1..48423b89a65 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { AddOAuthButtonProps, Credential } from '@/app/components/plugins/p 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, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,7 +19,7 @@ import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' import { AgentTools } from '../index' const toolProviderState = vi.hoisted(() => ({ - builtInTools: [] as ToolWithProvider[], + builtInTools: [] as ToolWithProvider[] | undefined, })) const pluginAuthState = vi.hoisted(() => ({ canOAuth: true as boolean | undefined, @@ -28,6 +28,21 @@ const pluginAuthState = vi.hoisted(() => ({ notAllowCustomCredential: false, invalidPluginCredentialInfo: vi.fn(), })) +const pluginInstallState = vi.hoisted(() => ({ + manifest: undefined as + | { + label: Record + latest_package_identifier: string + } + | undefined, + fallbackManifest: undefined as + | { + latest_package_identifier: string + } + | undefined, + invalidateBuiltInTools: vi.fn(), + invalidateInstalledPluginList: vi.fn(), +})) vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ ToolPickerContent: () =>
Mock tool picker
, @@ -48,6 +63,54 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ + uniqueIdentifier, + onSuccess, + }: { + uniqueIdentifier: string + onSuccess?: () => void + }) => ( + + ), +})) + +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => pluginInstallState.invalidateInstalledPluginList, + useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({ + data: + infos.length > 0 && pluginInstallState.manifest + ? { + data: { + list: infos.map(({ organization, plugin }) => ({ + plugin: { + ...pluginInstallState.manifest, + name: plugin, + plugin_id: `${organization}/${plugin}`, + }, + })), + }, + } + : undefined, + }), + usePluginManifestInfo: (pluginId: string) => ({ + data: + pluginId && pluginInstallState.fallbackManifest + ? { + data: { + plugin: pluginInstallState.fallbackManifest, + }, + } + : undefined, + }), +})) + +vi.mock('@/utils/get-icon', () => ({ + getIconFromMarketPlace: (pluginId: string) => `https://marketplace.example.com/${pluginId}/icon`, +})) + vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => ({ default: ({ buttonText, onUpdate, renderTrigger }: AddOAuthButtonProps) => { if (renderTrigger) { @@ -99,6 +162,7 @@ vi.mock('@/service/use-tools', () => ({ useAllCustomTools: () => ({ data: [] }), useAllWorkflowTools: () => ({ data: [] }), useAllMCPTools: () => ({ data: [] }), + useInvalidateAllBuiltInTools: () => pluginInstallState.invalidateBuiltInTools, useInvalidToolsByType: () => vi.fn(), })) @@ -181,6 +245,30 @@ const reflectedUnauthorizedNoCredentialDraft = { ], } satisfies AgentSoulConfigFormState +const reflectedUninstalledPluginDraft = { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'langgenius/google/google', + kind: 'provider', + name: 'langgenius/google/google', + pluginId: 'langgenius/google', + iconClassName: 'i-custom-public-other-default-tool-icon', + providerType: 'plugin', + credentialType: 'unauthorized', + credentialVariant: 'unauthorized', + actions: [ + { + id: 'langgenius/google/google:search', + name: 'search', + toolName: 'search', + description: '', + }, + ], + }, + ], +} satisfies AgentSoulConfigFormState + const reflectedUnauthorizedOAuthCredentialTypeDraft = { ...defaultAgentSoulConfigFormState, tools: [ @@ -369,6 +457,10 @@ describe('AgentTools', () => { pluginAuthState.canApiKey = false pluginAuthState.credentials = [] pluginAuthState.notAllowCustomCredential = false + pluginInstallState.manifest = undefined + pluginInstallState.fallbackManifest = undefined + pluginInstallState.invalidateBuiltInTools.mockResolvedValue(undefined) + pluginInstallState.invalidateInstalledPluginList.mockResolvedValue(undefined) }) describe('User Interactions', () => { @@ -528,6 +620,77 @@ describe('AgentTools', () => { expect(screen.getByText('Google Search')).toBeInTheDocument() }) + it('should let users install a missing provider and show its marketplace icon', async () => { + const user = userEvent.setup() + pluginInstallState.manifest = { + label: { + en_US: 'Google Tools', + }, + latest_package_identifier: 'langgenius/google:1.0.0@checksum', + } + renderAgentTools(reflectedUninstalledPluginDraft) + + expect(screen.getByRole('button', { name: 'Google Tools' })).toBeInTheDocument() + expect( + screen.getByText('https://marketplace.example.com/langgenius/google/icon'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'tools.notAuthorized', + }), + ).not.toBeInTheDocument() + + const installButton = screen.getByRole('button', { + name: 'workflow.nodes.agent.pluginInstaller.install', + }) + expect(installButton).toHaveAttribute( + 'data-unique-identifier', + 'langgenius/google:1.0.0@checksum', + ) + + await user.click(installButton) + + await waitFor(() => { + expect(pluginInstallState.invalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(pluginInstallState.invalidateInstalledPluginList).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep install actionable when batch marketplace metadata is unavailable', () => { + pluginInstallState.fallbackManifest = { + latest_package_identifier: 'langgenius/google:0.0.1@fallback', + } + renderAgentTools(reflectedUninstalledPluginDraft) + + expect( + screen.getByRole('button', { + name: 'google', + }), + ).toBeInTheDocument() + expect( + screen.getByText('https://marketplace.example.com/langgenius/google/icon'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'workflow.nodes.agent.pluginInstaller.install', + }), + ).toHaveAttribute('data-unique-identifier', 'langgenius/google:0.0.1@fallback') + }) + + it('should wait for the provider catalog before showing an uninstalled status', () => { + toolProviderState.builtInTools = undefined + renderAgentTools(reflectedUnauthorizedNoCredentialDraft) + + expect( + screen.queryByText('plugin.detailPanel.toolSelector.uninstalledTitle'), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'tools.notAuthorized', + }), + ).not.toBeInTheDocument() + }) + it('should hide unauthorized status when reflected provider tools do not require credentials', () => { toolProviderState.builtInTools = [duckDuckGoProvider] renderAgentTools(reflectedUnauthorizedNoCredentialDraft) 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 52d1f11025c..ad1a9b3cd2f 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,5 +1,6 @@ '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' @@ -25,12 +26,18 @@ import { 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 { getIconFromMarketPlace } from '@/utils/get-icon' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { ConfigureSectionEmpty } from '../common/empty' @@ -47,6 +54,12 @@ import { import { ProviderToolSettingsDialog } from './provider-tool/dialog' import { AgentProviderToolItem } from './provider-tool/item' +type DisplayAgentProviderTool = AgentProviderTool & { + isInstalled?: boolean +} + +type DisplayAgentTool = AgentCliTool | DisplayAgentProviderTool + const AgentToolItem = memo( ({ tool, @@ -56,8 +69,9 @@ const AgentToolItem = memo( onDeleteProviderToolAction, onEditCliTool, onCredentialChange, + onPluginInstalled, }: { - tool: AgentTool + tool: DisplayAgentTool onConfigureAction: (target: ToolSettingTarget) => void onDeleteCliTool: (toolId: string) => void onDeleteProviderTool: (toolId: string) => void @@ -68,6 +82,7 @@ const AgentToolItem = memo( credentialId?: string, credentialType?: AgentProviderTool['credentialType'], ) => void + onPluginInstalled: () => void }) => { const [isExpanded, setIsExpanded] = useState(false) @@ -101,12 +116,14 @@ const AgentToolItem = memo( return ( ) } @@ -125,6 +142,7 @@ function useAgentToolProviderMap() { 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 : [] @@ -136,6 +154,14 @@ function useAgentToolProviderMap() { ...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) @@ -145,14 +171,43 @@ function useAgentToolProviderMap() { } }) - return providers + return { + providerById: providers, + resolvedProviderTypes, + } }, [buildInTools, customTools, workflowTools, mcpTools]) } -function getLocalizedText(text: Record | undefined, language: string) { +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'] { @@ -191,16 +246,42 @@ function getProviderCredentialVariant( : ('unauthorized' as const) } -function useDisplayTools(tools: AgentTool[], providerById: Map) { +function useDisplayTools( + tools: AgentTool[], + providerById: Map, + resolvedProviderTypes: Set, + marketplacePluginById: Map, +) { const language = useGetLanguage() return useMemo(() => { - return tools.map((tool) => { + return tools.map((tool): DisplayAgentTool => { if (tool.kind !== 'provider') return tool const provider = providerById.get(tool.id) ?? providerById.get(tool.name) - if (!provider) return tool + if (!provider) { + const providerPluginId = getProviderPluginId(tool) + const marketplacePlugin = marketplacePluginById.get(providerPluginId) + + return { + ...tool, + isInstalled: resolvedProviderTypes.has(tool.providerType) ? false : undefined, + pluginId: tool.pluginId ?? providerPluginId, + pluginUniqueIdentifier: + tool.pluginUniqueIdentifier ?? marketplacePlugin?.latest_package_identifier, + displayName: + tool.displayName ?? + getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ?? + marketplacePlugin?.name ?? + getProviderDisplayName(tool), + icon: + tool.icon ?? + (marketplacePlugin && providerPluginId + ? getIconFromMarketPlace(providerPluginId) + : undefined), + } + } const providerToolByName = new Map( provider.tools.map((providerTool) => [providerTool.name, providerTool]), @@ -209,6 +290,7 @@ function useDisplayTools(tools: AgentTool[], providerById: Map(addToolDefaultView) - const providerById = useAgentToolProviderMap() + const { providerById } = useAgentToolProviderMap() const openToolPicker = useCallback(() => { setView('tool-picker') @@ -402,7 +484,9 @@ export function AgentTools() { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom) - const providerById = useAgentToolProviderMap() + const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() + const invalidateInstalledPluginList = useInvalidateInstalledPluginList() + const { providerById, resolvedProviderTypes } = useAgentToolProviderMap() const tools = useAtomValue(agentComposerToolsAtom) const selectedTools = useSelectedProviderTools() const addTools = useSetAtom(addProviderToolsAtom) @@ -432,11 +516,53 @@ export function AgentTools() { }, [setProviderToolCredential], ) + const handlePluginInstalled = useCallback(() => { + void Promise.allSettled([invalidateAllBuiltInTools(), invalidateInstalledPluginList()]) + }, [invalidateAllBuiltInTools, invalidateInstalledPluginList]) const visibleTools = useMemo( () => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')), [tools], ) - const displayTools = useDisplayTools(visibleTools, providerById) + 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 displayTools = useDisplayTools( + visibleTools, + providerById, + resolvedProviderTypes, + marketplacePluginById, + ) /* * knip-ignore-start * Keep this disabled sync logic while backend credential snapshots are being investigated. @@ -558,6 +684,7 @@ export function AgentTools() { onDeleteProviderToolAction={deleteProviderToolAction} onEditCliTool={editCliTool} onCredentialChange={handleProviderCredentialChange} + onPluginInstalled={handlePluginInstalled} /> )) )} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx index ab60f638d66..bd083c85302 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx @@ -21,9 +21,12 @@ import { AuthCategory, Authorized, usePluginAuth } from '@/app/components/plugin import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node' import { CollectionType } from '@/app/components/tools/types' import BlockIcon from '@/app/components/workflow/block-icon' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { BlockEnum } from '@/app/components/workflow/types' import useTheme from '@/hooks/use-theme' +import { usePluginManifestInfo } from '@/service/use-plugins' import { Theme } from '@/types/app' +import { getIconFromMarketPlace } from '@/utils/get-icon' import { useAgentOrchestrateReadOnly } from '../../read-only-context' function ProviderIcon({ @@ -119,6 +122,36 @@ function UnauthorizedCredentialStatus({ ) } +function UninstalledPluginStatus({ + installInfo, + extraIdentifiers, + onInstall, +}: { + installInfo?: string + extraIdentifiers: string[] + onInstall: () => void +}) { + const { t } = useTranslation() + + if (installInfo) { + return ( + + ) + } + + return ( + + {t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' })} + + + ) +} + function CredentialStatus({ tool, onCredentialChange, @@ -231,19 +264,23 @@ const ProviderToolActionItem = memo( export const AgentProviderToolItem = memo( ({ tool, + isInstalled, isExpanded, onOpenChange, onConfigureAction, onRemoveAction, onRemoveProvider, onCredentialChange, + onInstall, }: { tool: AgentProviderTool + isInstalled?: boolean isExpanded: boolean onOpenChange: (open: boolean) => void onConfigureAction: (target: ToolSettingTarget) => void onRemoveAction: (actionId: string) => void onRemoveProvider: () => void + onInstall: () => void onCredentialChange: ( credentialId?: string, credentialType?: AgentProviderTool['credentialType'], @@ -252,7 +289,20 @@ export const AgentProviderToolItem = memo( const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const { theme } = useTheme() - const icon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon + const shouldFetchPluginManifest = + isInstalled === false && !!tool.pluginId && !tool.pluginUniqueIdentifier + const { data: pluginManifestData } = usePluginManifestInfo( + shouldFetchPluginManifest ? tool.pluginId! : '', + ) + const pluginManifest = pluginManifestData?.data.plugin + const configuredIcon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon + const icon = + configuredIcon ?? + (pluginManifest && tool.pluginId ? getIconFromMarketPlace(tool.pluginId) : undefined) + const installInfo = tool.pluginUniqueIdentifier ?? pluginManifest?.latest_package_identifier + const installIdentifiers = [tool.pluginId, tool.id].filter((identifier): identifier is string => + Boolean(identifier), + ) const displayName = tool.displayName ?? tool.name return ( @@ -277,32 +327,41 @@ export const AgentProviderToolItem = memo( {!readOnly && ( - <> - - $['agentDetail.configure.tools.moreActions'], { - name: tool.name, - })} - className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + + $['agentDetail.configure.tools.moreActions'], { + name: tool.name, + })} + className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + > + + {t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} + + + + + - - {t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} - - - - - - - {t(($) => $['agentDetail.configure.tools.removeProvider'])} - - - - - + + {t(($) => $['agentDetail.configure.tools.removeProvider'])} + + + + )} + {isInstalled === false && ( +
+ +
+ )} + {!readOnly && isInstalled === true && ( + )}