diff --git a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx
index 094518c0726..50336b916e2 100644
--- a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx
@@ -10,7 +10,7 @@ import useTheme from '@/hooks/use-theme'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { Theme } from '@/types/app'
import ToolBrowser from '../tool-browser'
-import { createToolProvider } from './factories'
+import { createPlugin, createToolProvider } from './factories'
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
@@ -333,6 +333,128 @@ describe('ToolBrowser', () => {
expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument()
})
+ it('excludes an installed marketplace plugin even when its provider does not match the search', async () => {
+ const installedPlugin = createPlugin({
+ plugin_id: 'installed-search',
+ label: { en_US: 'Search Plugin', zh_Hans: 'Search Plugin' },
+ })
+ mockUseMarketplacePlugins.mockImplementation((params) =>
+ createMarketplacePluginsMock({
+ data: params ? createMarketplaceData([installedPlugin]) : undefined,
+ }),
+ )
+
+ render(
+ ,
+ true,
+ )
+
+ expect(await screen.findByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
+ expect(screen.queryByText('Search Plugin')).not.toBeInTheDocument()
+ expect(screen.queryByText('Web Toolkit')).not.toBeInTheDocument()
+ })
+
+ it('excludes installed plugins across marketplace pages when filtering only by tag', async () => {
+ const installedPlugins = ['first', 'second'].map((page) =>
+ createPlugin({
+ plugin_id: `installed-${page}`,
+ label: { en_US: `Installed ${page}`, zh_Hans: `Installed ${page}` },
+ }),
+ )
+ const availablePlugin = createPlugin({
+ plugin_id: 'available-plugin',
+ label: { en_US: 'Available Plugin', zh_Hans: 'Available Plugin' },
+ })
+ mockUseMarketplacePlugins.mockImplementation((params) =>
+ createMarketplacePluginsMock({
+ data: params
+ ? {
+ pages: [
+ {
+ plugins: [...installedPlugins.slice(0, 1), availablePlugin],
+ total: 3,
+ page: 1,
+ page_size: 2,
+ },
+ { plugins: installedPlugins.slice(1), total: 3, page: 2, page_size: 2 },
+ ],
+ pageParams: [1, 2],
+ }
+ : undefined,
+ }),
+ )
+
+ render(
+
+ createToolProvider({ id: `${plugin.plugin_id}/provider`, plugin_id: plugin.plugin_id }),
+ )}
+ customTools={[]}
+ workflowTools={[]}
+ mcpTools={[]}
+ />,
+ true,
+ )
+
+ expect(await screen.findByText('Available Plugin')).toBeInTheDocument()
+ expect(screen.queryByText('Installed first')).not.toBeInTheDocument()
+ expect(screen.queryByText('Installed second')).not.toBeInTheDocument()
+ })
+
+ it('removes a newly installed marketplace result when the provider list refreshes', async () => {
+ const newlyInstalledPlugin = createPlugin({
+ plugin_id: 'new-plugin',
+ label: { en_US: 'New Plugin', zh_Hans: 'New Plugin' },
+ })
+ const availablePlugin = createPlugin({
+ plugin_id: 'available-plugin',
+ label: { en_US: 'Available Plugin', zh_Hans: 'Available Plugin' },
+ })
+ const data = createMarketplaceData([newlyInstalledPlugin, availablePlugin])
+ mockUseMarketplacePlugins.mockImplementation((params) =>
+ createMarketplacePluginsMock({ data: params ? data : undefined }),
+ )
+ const props = {
+ searchText: 'plugin',
+ tags: [],
+ onSelect: vi.fn(),
+ buildInTools: [],
+ customTools: [],
+ workflowTools: [],
+ mcpTools: [],
+ }
+ const { rerender } = render(, true)
+
+ expect(await screen.findByText('New Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Available Plugin')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('New Plugin')).not.toBeInTheDocument()
+ expect(screen.getByText('Available Plugin')).toBeInTheDocument()
+ })
+
it('debounces marketplace requests across search and tag changes', async () => {
const baseProps = {
onSelect: vi.fn(),
diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
index c37614a3700..3faf345a598 100644
--- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
@@ -27,7 +27,7 @@ import {
} from '@/service/use-tools'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { Theme } from '@/types/app'
-import ToolPicker from '../tool-picker'
+import ToolPicker, { ToolPickerContent } from '../tool-picker'
const mockNotify = vi.fn()
const mockInvalidateBuiltInTools = vi.fn()
@@ -550,6 +550,140 @@ describe('ToolPicker', () => {
])
})
+ it.each([
+ {
+ scope: 'custom',
+ filter: 'search',
+ visibleProvider: 'Custom Provider',
+ hiddenProvider: 'Workflow Tool',
+ },
+ {
+ scope: 'custom',
+ filter: 'tag',
+ visibleProvider: 'Custom Provider',
+ hiddenProvider: 'Workflow Tool',
+ },
+ {
+ scope: 'workflow',
+ filter: 'search',
+ visibleProvider: 'Workflow Tool',
+ hiddenProvider: 'Custom Provider',
+ },
+ {
+ scope: 'workflow',
+ filter: 'tag',
+ visibleProvider: 'Workflow Tool',
+ hiddenProvider: 'Custom Provider',
+ },
+ ] as const)(
+ 'excludes installed marketplace plugins from $scope pickers filtered by $filter',
+ async ({ scope, filter, visibleProvider, hiddenProvider }) => {
+ const user = userEvent.setup()
+ const installedPlugin = createPlugin({
+ plugin_id: 'installed-plugin',
+ label: { en_US: 'Installed Plugin' },
+ })
+ const availablePlugin = createPlugin({
+ plugin_id: 'available-plugin',
+ label: { en_US: 'Available Plugin' },
+ })
+ mockUseAllBuiltInTools.mockReturnValue({
+ data: [createToolProvider({ ...builtInTools[0], plugin_id: installedPlugin.plugin_id })],
+ } as ReturnType)
+ mockUseMarketplacePlugins.mockImplementation(
+ (params) =>
+ ({
+ data: params
+ ? {
+ pages: [
+ {
+ plugins: [installedPlugin, availablePlugin],
+ total: 2,
+ page: 1,
+ page_size: 40,
+ },
+ ],
+ pageParams: [1],
+ }
+ : undefined,
+ isFetching: false,
+ }) as ReturnType,
+ )
+
+ renderToolPicker({ isShow: true, scope, selectedTools: [] })
+
+ expect(screen.getByText(visibleProvider)).toBeInTheDocument()
+ expect(screen.queryByText(hiddenProvider)).not.toBeInTheDocument()
+ expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
+
+ if (filter === 'search') {
+ await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'plugin')
+ } else {
+ await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
+ await user.click(screen.getByRole('checkbox', { name: 'Weather' }))
+ await user.keyboard('{Escape}')
+ }
+
+ expect(await screen.findByText('Available Plugin')).toBeInTheDocument()
+ expect(screen.queryByText('Installed Plugin')).not.toBeInTheDocument()
+ expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
+ },
+ )
+
+ it.each(['custom', 'workflow'] as const)(
+ 'removes newly installed marketplace plugins when %s picker data refreshes',
+ async (scope) => {
+ const user = userEvent.setup()
+ const newlyInstalledPlugin = createPlugin({
+ plugin_id: 'new-plugin',
+ label: { en_US: 'New Plugin' },
+ })
+ const availablePlugin = createPlugin({
+ plugin_id: 'available-plugin',
+ label: { en_US: 'Available Plugin' },
+ })
+ mockUseMarketplacePlugins.mockImplementation(
+ (params) =>
+ ({
+ data: params
+ ? {
+ pages: [
+ {
+ plugins: [newlyInstalledPlugin, availablePlugin],
+ total: 2,
+ page: 1,
+ page_size: 40,
+ },
+ ],
+ pageParams: [1],
+ }
+ : undefined,
+ isFetching: false,
+ }) as ReturnType,
+ )
+ const props = { scope, onSelect: vi.fn(), onSelectMultiple: vi.fn() }
+ const { rerender } = renderWithConsoleQuery(, {
+ systemFeatures: { enable_marketplace: true },
+ })
+
+ await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'plugin')
+ expect(await screen.findByText('New Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Available Plugin')).toBeInTheDocument()
+
+ mockUseAllBuiltInTools.mockReturnValue({
+ data: [
+ ...builtInTools,
+ createToolProvider({ id: 'new-provider', plugin_id: newlyInstalledPlugin.plugin_id }),
+ ],
+ } as ReturnType)
+ rerender()
+
+ expect(screen.queryByText('New Plugin')).not.toBeInTheDocument()
+ expect(screen.getByText('Available Plugin')).toBeInTheDocument()
+ expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
+ },
+ )
+
it('should create a custom collection from the add button and refresh custom tools', async () => {
const user = userEvent.setup()
renderToolPicker({
diff --git a/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx
index 660c501bc14..c8134b9ecf1 100644
--- a/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx
+++ b/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx
@@ -1,5 +1,6 @@
-import { render, screen } from '@testing-library/react'
+import { render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { useTranslation } from 'react-i18next'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { createPlugin } from '../../__tests__/factories'
import useStickyScroll, { ScrollPosition } from '../../use-sticky-scroll'
@@ -61,14 +62,61 @@ vi.mock('@/utils/var', async (importOriginal) => ({
}))
describe('Marketplace plugin selector components', () => {
+ let i18n: ReturnType['i18n']
+
beforeEach(() => {
vi.clearAllMocks()
+ i18n = renderHook(() => useTranslation()).result.current.i18n
+ i18n.language = 'en-US'
vi.mocked(useStickyScroll).mockReturnValue({
handleScroll: vi.fn(),
scrollPosition: ScrollPosition.belowTheWrap,
})
})
+ it.each([
+ { locale: 'zh-Hans', language: 'zh_Hans', label: '搜索插件', brief: '搜索文档' },
+ { locale: 'ja-JP', language: 'ja_JP', label: '検索プラグイン', brief: 'ドキュメントを検索' },
+ {
+ locale: 'pt-BR',
+ language: 'pt_BR',
+ label: 'Plugin de pesquisa',
+ brief: 'Pesquisa documentos',
+ },
+ ])('should display marketplace metadata in $locale', ({ locale, language, label, brief }) => {
+ i18n.language = locale
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(label)).toBeInTheDocument()
+ expect(screen.getByText(brief)).toBeInTheDocument()
+ expect(screen.queryByText('Search Plugin')).not.toBeInTheDocument()
+ expect(screen.queryByText('Searches documents')).not.toBeInTheDocument()
+ })
+
+ it('should fall back to English when marketplace metadata has no current-language translation', () => {
+ i18n.language = 'ja-JP'
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Search Plugin')).toBeInTheDocument()
+ expect(screen.getByText('Searches documents')).toBeInTheDocument()
+ })
+
it('should render marketplace plugin metadata and open install modal', async () => {
const user = userEvent.setup()
diff --git a/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx
index f04d6567854..29f26d4025b 100644
--- a/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx
+++ b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
-import { useLocale } from '@/context/i18n'
+import { useGetLanguage } from '@/context/i18n'
import { formatNumber } from '@/utils/format'
import Action from './action'
@@ -19,9 +19,9 @@ type Props = Readonly<{
function Item({ payload }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
- const locale = useLocale()
+ const language = useGetLanguage()
const getLocalizedText = (obj: Record | undefined) =>
- obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
+ obj?.[language] || obj?.['en-US'] || obj?.en_US || ''
const [isShowInstallModal, { setTrue: showInstallModal, setFalse: hideInstallModal }] =
useBoolean(false)
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission()
diff --git a/web/app/components/workflow/block-selector/tool-browser.tsx b/web/app/components/workflow/block-selector/tool-browser.tsx
index a684946446e..0f27ef410d3 100644
--- a/web/app/components/workflow/block-selector/tool-browser.tsx
+++ b/web/app/components/workflow/block-selector/tool-browser.tsx
@@ -83,6 +83,8 @@ type ToolBrowserProps = {
searchText: string
tags: ListProps['tags']
buildInTools: ToolWithProvider[]
+ /** Full installed plugin IDs when selectable providers are scoped. */
+ installedPluginIds?: ReadonlySet
customTools: ToolWithProvider[]
workflowTools: ToolWithProvider[]
mcpTools: ToolWithProvider[]
@@ -109,6 +111,7 @@ function ToolBrowser({
canNotSelectMultiple,
onSelectMultiple,
buildInTools,
+ installedPluginIds,
workflowTools,
customTools,
mcpTools = [],
@@ -233,9 +236,13 @@ function ToolBrowser({
)
const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } =
useMarketplacePlugins(marketplaceSearchParams)
+ const installedPluginLookup = installedPluginIds ?? providerMap
const notInstalledPlugins = useMemo(
- () => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
- [marketplacePluginsData?.pages],
+ () =>
+ marketplacePluginsData?.pages.flatMap((page) =>
+ page.plugins.filter((plugin) => !installedPluginLookup.has(plugin.plugin_id)),
+ ) ?? [],
+ [marketplacePluginsData?.pages, installedPluginLookup],
)
const pluginRef = useRef(null)
diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx
index 493d5cef10f..f5df700f804 100644
--- a/web/app/components/workflow/block-selector/tool-picker.tsx
+++ b/web/app/components/workflow/block-selector/tool-picker.tsx
@@ -70,6 +70,10 @@ export function ToolPickerContent({
select: (s) => s.enable_marketplace,
})
const { data: buildInTools } = useAllBuiltInTools()
+ const installedPluginIds = useMemo(
+ () => new Set(buildInTools?.map((provider) => provider.plugin_id || provider.id)),
+ [buildInTools],
+ )
const shouldFetchCustomTools = scope !== 'plugins' && scope !== 'workflow'
const { data: customTools } = useAllCustomTools(shouldFetchCustomTools)
const invalidateCustomTools = useInvalidateAllCustomTools()
@@ -176,6 +180,7 @@ export function ToolPickerContent({
onSelect={handleSelect as OnSelectBlock}
onSelectMultiple={handleSelectMultiple}
buildInTools={builtinToolList || []}
+ installedPluginIds={installedPluginIds}
customTools={customToolList || []}
workflowTools={workflowToolList || []}
mcpTools={mcpTools || []}