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 && (
+
)}