feat: add missing plugin installation to agent DSL imports (#39680)

This commit is contained in:
Joel 2026-07-28 16:23:42 +08:00 committed by GitHub
parent b97abe5328
commit ae0b66311d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 495 additions and 43 deletions

View File

@ -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[] }) => (
<div role="dialog" aria-label="Install missing plugins">
{`bundle-size:${fromDSLPayload.length}`}
</div>
),
}))
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(
<RosterLayout>
<div>Agent route content</div>
</RosterLayout>,
)
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')

View File

@ -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 <AgentsAccessGuard>{children}</AgentsAccessGuard>
return (
<AgentsAccessGuard>
<PluginDependency />
{children}
</AgentsAccessGuard>
)
}

View File

@ -1,3 +1,5 @@
'use client'
import { useCallback } from 'react'
import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle'
import { useStore } from './store'

View File

@ -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: {

View File

@ -197,8 +197,21 @@ const toToolRuntimeParameters = (settings: Record<string, unknown> | 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]),

View File

@ -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']

View File

@ -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,

View File

@ -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<string, string>
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: () => <div>Mock tool picker</div>,
@ -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
}) => (
<button type="button" data-unique-identifier={uniqueIdentifier} onClick={onSuccess}>
workflow.nodes.agent.pluginInstaller.install
</button>
),
}))
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)

View File

@ -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 (
<AgentProviderToolItem
tool={tool}
isInstalled={tool.isInstalled}
isExpanded={isExpanded}
onOpenChange={setIsExpanded}
onConfigureAction={onConfigureAction}
onRemoveAction={handleRemoveProviderAction}
onRemoveProvider={handleRemoveProvider}
onCredentialChange={handleCredentialChange}
onInstall={onPluginInstalled}
/>
)
}
@ -125,6 +142,7 @@ function useAgentToolProviderMap() {
return useMemo(() => {
const providers = new Map<string, ToolWithProvider>()
const resolvedProviderTypes = new Set<AgentProviderTool['providerType']>()
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<string, string> | undefined, language: string) {
function getLocalizedText(text: Partial<Record<string, string>> | 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<string, ToolWithProvider>) {
function useDisplayTools(
tools: AgentTool[],
providerById: Map<string, ToolWithProvider>,
resolvedProviderTypes: Set<AgentProviderTool['providerType']>,
marketplacePluginById: Map<string, MarketplacePlugin>,
) {
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<string, ToolWithP
return {
...tool,
isInstalled: true,
displayName: tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name,
icon: tool.icon ?? provider.icon,
iconDark: tool.iconDark ?? provider.icon_dark,
@ -234,9 +316,9 @@ function useDisplayTools(tools: AgentTool[], providerById: Map<string, ToolWithP
action.description || getLocalizedText(providerTool.description, language) || '',
}
}),
} satisfies AgentProviderTool
} satisfies DisplayAgentProviderTool
})
}, [language, providerById, tools])
}, [language, marketplacePluginById, providerById, resolvedProviderTypes, tools])
}
function AddToolMenuItem({
@ -295,7 +377,7 @@ function AddToolMenu({
const { t } = useTranslation('agentV2')
const [open, setOpen] = useState(false)
const [view, setView] = useState<AddToolMenuView>(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<string>()
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}
/>
))
)}

View File

@ -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 (
<InstallPluginButton
size="small"
uniqueIdentifier={installInfo}
extraIdentifiers={extraIdentifiers}
onSuccess={onInstall}
/>
)
}
return (
<span className="flex h-6 shrink-0 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 system-xs-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px]">
{t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' })}
<StatusDot className="ml-2" status="warning" />
</span>
)
}
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(
</span>
</CollapsibleTrigger>
{!readOnly && (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['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"
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(($) => $['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"
>
<span className="sr-only">
{t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })}
</span>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
variant="destructive"
className="gap-2"
onClick={onRemoveProvider}
>
<span className="sr-only">
{t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })}
</span>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
variant="destructive"
className="gap-2"
onClick={onRemoveProvider}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span>{t(($) => $['agentDetail.configure.tools.removeProvider'])}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CredentialStatus tool={tool} onCredentialChange={onCredentialChange} />
</>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span>{t(($) => $['agentDetail.configure.tools.removeProvider'])}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isInstalled === false && (
<div className="shrink-0">
<UninstalledPluginStatus
installInfo={installInfo}
extraIdentifiers={installIdentifiers}
onInstall={onInstall}
/>
</div>
)}
{!readOnly && isInstalled === true && (
<CredentialStatus tool={tool} onCredentialChange={onCredentialChange} />
)}
</div>