feat: add missing plugin installation to agent DSL imports (#39680)
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle'
|
||||
import { useStore } from './store'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
+165
-2
@@ -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)
|
||||
|
||||
+138
-11
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
+85
-26
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user