-
-
-
-
+
{t('modelProvider.models', { ns: 'common' })}
+
+ {showWarning &&
}
+ {showWarning && (
+
+
+ {t(warningTextKey, { ns: 'common' })}
+
)}
- >
- {showWarning &&
}
- {showWarning && (
-
-
- {t(warningTextKey, { ns: 'common' })}
-
- )}
-
-
+
{IS_CLOUD_EDITION &&
}
- {!hasConfiguredProviders && (
+ {!filteredConfiguredProviders?.length && (
@@ -210,46 +167,23 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
{t('modelProvider.emptyProviderTip', { ns: 'common' })}
)}
- {!!creditsBackedProviders.length && (
-
-
-
{t('modelProvider.creditsBackedProviders', { ns: 'common' })}
-
{t('modelProvider.creditsBackedProvidersDesc', { ns: 'common' })}
-
-
- {creditsBackedProviders.map(provider => (
-
- ))}
-
-
- )}
- {!!otherConfiguredProviders.length && (
-
- {t('modelProvider.configuredProviders', { ns: 'common' })}
-
- {otherConfiguredProviders.map(provider => (
-
- ))}
-
-
+ {!!filteredConfiguredProviders?.length && (
+
+ {filteredConfiguredProviders?.map(provider => (
+
+ ))}
+
)}
{!!filteredNotConfiguredProviders?.length && (
-
- {t('modelProvider.toBeConfigured', { ns: 'common' })}
-
+ <>
+
{t('modelProvider.toBeConfigured', { ns: 'common' })}
+
{filteredNotConfiguredProviders?.map(provider => (
{
/>
))}
-
+ >
)}
{
enableMarketplace && (
@@ -267,13 +201,6 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
/>
)
}
- {showUpdateSettingModal && referenceSetting && (
-
setShowUpdateSettingModal(false)}
- onSave={setReferenceSettings}
- />
- )}
)
}
diff --git a/web/app/components/header/tools-nav/__tests__/index.spec.tsx b/web/app/components/header/tools-nav/__tests__/index.spec.tsx
index 361e6f8b84..d5646e09d7 100644
--- a/web/app/components/header/tools-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/tools-nav/__tests__/index.spec.tsx
@@ -28,7 +28,7 @@ describe('ToolsNav', () => {
render()
const link = screen.getByRole('link')
- expect(link).toHaveAttribute('href', '/tools')
+ expect(link).toHaveAttribute('href', '/integrations/tools/built-in')
expect(screen.getByText('common.menus.tools')).toBeInTheDocument()
expect(screen.getByTestId('icon-hammer-line')).toBeInTheDocument()
@@ -57,6 +57,15 @@ describe('ToolsNav', () => {
expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument()
expect(screen.queryByTestId('icon-hammer-line')).not.toBeInTheDocument()
})
+
+ it('should render active state for the integrations segment', () => {
+ mockUseSelectedLayoutSegment.mockReturnValue('integrations')
+
+ render()
+
+ expect(screen.getByRole('link')).toHaveClass('bg-components-main-nav-nav-button-bg-active')
+ expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument()
+ })
})
describe('Props', () => {
diff --git a/web/app/components/header/tools-nav/index.tsx b/web/app/components/header/tools-nav/index.tsx
index 06d19382ac..8c7b73f163 100644
--- a/web/app/components/header/tools-nav/index.tsx
+++ b/web/app/components/header/tools-nav/index.tsx
@@ -6,6 +6,7 @@ import {
RiHammerLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
+import { buildIntegrationPath } from '@/app/components/tools/integration-routes'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
@@ -18,11 +19,11 @@ const ToolsNav = ({
}: ToolsNavProps) => {
const { t } = useTranslation()
const selectedSegment = useSelectedLayoutSegment()
- const activated = selectedSegment === 'tools'
+ const activated = selectedSegment === 'integrations' || selectedSegment === 'tools'
return (
{
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx
index 71c1a00a2c..f6373a337e 100644
--- a/web/app/components/main-nav/__tests__/index.spec.tsx
+++ b/web/app/components/main-nav/__tests__/index.spec.tsx
@@ -214,7 +214,7 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
- expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools?section=provider')
+ expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
})
diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx
index 975e9c98e2..63874347e8 100644
--- a/web/app/components/main-nav/index.tsx
+++ b/web/app/components/main-nav/index.tsx
@@ -7,6 +7,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
+import { buildIntegrationPath } from '@/app/components/tools/integration-routes'
import { useAppContext } from '@/context/app-context'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
@@ -59,9 +60,9 @@ const MainNav = ({
...(!isCurrentWorkspaceDatasetOperator
? [
{
- href: '/tools?section=provider',
+ href: buildIntegrationPath('provider'),
label: t('mainNav.integrations', { ns: 'common' }),
- active: (path: string) => path.startsWith('/tools'),
+ active: (path: string) => path.startsWith('/integrations') || path.startsWith('/tools'),
icon: 'i-custom-vender-main-nav-integrations',
activeIcon: 'i-custom-vender-main-nav-integrations-active',
},
diff --git a/web/app/components/plugins/__tests__/plugin-routes.spec.ts b/web/app/components/plugins/__tests__/plugin-routes.spec.ts
new file mode 100644
index 0000000000..a329e7fec0
--- /dev/null
+++ b/web/app/components/plugins/__tests__/plugin-routes.spec.ts
@@ -0,0 +1,26 @@
+import { getLegacyPluginRedirectPath } from '../plugin-routes'
+
+describe('plugin routes', () => {
+ it.each([
+ [{}, '/integrations'],
+ [{ tab: 'plugins' }, '/integrations'],
+ [{ tab: ['plugins', 'discover'] }, '/integrations'],
+ ])('redirects installed plugin URLs for search params %j', (searchParams, expected) => {
+ expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected)
+ })
+
+ it.each([
+ { tab: 'discover' },
+ { tab: 'all' },
+ { tab: 'tool' },
+ { tab: 'model' },
+ { tab: 'trigger' },
+ { tab: 'agent-strategy' },
+ { tab: 'extension' },
+ { tab: 'datasource' },
+ { tab: 'bundle' },
+ { tab: 'unsupported' },
+ ])('does not redirect marketplace or unsupported plugin URLs for search params %j', (searchParams) => {
+ expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined()
+ })
+})
diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx
index 6347fde86f..f789f162be 100644
--- a/web/app/components/plugins/plugin-page/context-provider.tsx
+++ b/web/app/components/plugins/plugin-page/context-provider.tsx
@@ -28,13 +28,15 @@ const parseAsPluginPageTab = parseAsStringEnum(PLUGIN_PAGE_TAB_VA
type PluginPageContextProviderProps = {
children: ReactNode
+ initialFilters?: FilterState
}
export const PluginPageContextProvider = ({
children,
+ initialFilters,
}: PluginPageContextProviderProps) => {
const containerRef = useRef(null)
- const [filters, setFilters] = useState({
+ const [filters, setFilters] = useState(initialFilters ?? {
categories: [],
tags: [],
searchQuery: '',
diff --git a/web/app/components/plugins/plugin-routes.ts b/web/app/components/plugins/plugin-routes.ts
new file mode 100644
index 0000000000..f6156904b2
--- /dev/null
+++ b/web/app/components/plugins/plugin-routes.ts
@@ -0,0 +1,32 @@
+import { PLUGIN_TYPE_SEARCH_MAP } from './marketplace/constants'
+
+export type LegacyPluginsSearchParams = Record
+
+const INSTALLED_PLUGINS_TAB = 'plugins'
+const MARKETPLACE_TAB = 'discover'
+
+const marketplacePluginTabs = new Set([
+ MARKETPLACE_TAB,
+ ...Object.values(PLUGIN_TYPE_SEARCH_MAP),
+])
+
+const getFirstSearchParamValue = (value: string | string[] | undefined) => {
+ if (Array.isArray(value))
+ return value[0]
+
+ return value
+}
+
+export const getLegacyPluginRedirectPath = (
+ searchParams: LegacyPluginsSearchParams = {},
+) => {
+ const tab = getFirstSearchParamValue(searchParams.tab)
+
+ if (!tab || tab === INSTALLED_PLUGINS_TAB)
+ return '/integrations'
+
+ if (marketplacePluginTabs.has(tab))
+ return undefined
+
+ return undefined
+}
diff --git a/web/app/components/tools/__tests__/integration-routes.spec.ts b/web/app/components/tools/__tests__/integration-routes.spec.ts
new file mode 100644
index 0000000000..c9c902382a
--- /dev/null
+++ b/web/app/components/tools/__tests__/integration-routes.spec.ts
@@ -0,0 +1,64 @@
+import {
+ buildIntegrationPath,
+ getIntegrationRedirectPathByLegacyToolsSearchParams,
+ getIntegrationRouteTargetBySlug,
+ integrationPathBySection,
+} from '../integration-routes'
+
+describe('integration routes', () => {
+ it('maps integration sections to canonical paths', () => {
+ expect(integrationPathBySection).toEqual({
+ 'provider': '/integrations/model-provider',
+ 'builtin': '/integrations/tools/built-in',
+ 'custom-tool': '/integrations/tools/swagger-api',
+ 'workflow-tool': '/integrations/tools/workflow',
+ 'mcp': '/integrations/tools/mcp',
+ 'data-source': '/integrations/data-source',
+ 'api-based-extension': '/integrations/tools/api-extension',
+ 'trigger': '/integrations/trigger',
+ 'agent-strategy': '/integrations/agent-strategy',
+ 'extension': '/integrations/extension',
+ })
+ expect(buildIntegrationPath('custom-tool')).toBe('/integrations/tools/swagger-api')
+ })
+
+ it.each([
+ [undefined, { type: 'redirect', destination: '/integrations/model-provider' }],
+ [[], { type: 'redirect', destination: '/integrations/model-provider' }],
+ [['model-provider'], { type: 'section', section: 'provider' }],
+ [['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
+ [['tools', 'built-in'], { type: 'section', section: 'builtin' }],
+ [['tools', 'swagger-api'], { type: 'section', section: 'custom-tool' }],
+ [['tools', 'workflow'], { type: 'section', section: 'workflow-tool' }],
+ [['tools', 'mcp'], { type: 'section', section: 'mcp' }],
+ [['data-source'], { type: 'section', section: 'data-source' }],
+ [['tools', 'api-extension'], { type: 'section', section: 'api-based-extension' }],
+ [['trigger'], { type: 'section', section: 'trigger' }],
+ [['agent-strategy'], { type: 'section', section: 'agent-strategy' }],
+ [['extension'], { type: 'section', section: 'extension' }],
+ [['model-providers'], { type: 'not-found' }],
+ [['data-sources'], { type: 'not-found' }],
+ [['api-extensions'], { type: 'not-found' }],
+ [['tools', 'trigger'], { type: 'not-found' }],
+ [['tools', 'agent-strategy'], { type: 'not-found' }],
+ [['tools', 'extension'], { type: 'not-found' }],
+ [['missing'], { type: 'not-found' }],
+ ])('resolves slug %j', (slug, expected) => {
+ expect(getIntegrationRouteTargetBySlug(slug)).toEqual(expected)
+ })
+
+ it.each([
+ [{}, '/integrations/tools/built-in'],
+ [{ section: 'provider' }, '/integrations/model-provider'],
+ [{ section: 'builtin' }, '/integrations/tools/built-in'],
+ [{ category: 'builtin' }, '/integrations/tools/built-in'],
+ [{ category: 'api' }, '/integrations/tools/swagger-api'],
+ [{ category: 'workflow' }, '/integrations/tools/workflow'],
+ [{ category: 'mcp' }, '/integrations/tools/mcp'],
+ [{ section: 'data-source' }, '/integrations/data-source'],
+ [{ section: 'api-based-extension' }, '/integrations/tools/api-extension'],
+ [{ section: 'custom-tool', category: 'api', q: 'slack', tags: ['a', 'b'] }, '/integrations/tools/swagger-api?q=slack&tags=a&tags=b'],
+ ])('builds legacy /tools redirect for search params %j', (searchParams, expected) => {
+ expect(getIntegrationRedirectPathByLegacyToolsSearchParams(searchParams)).toBe(expected)
+ })
+})
diff --git a/web/app/components/tools/__tests__/integrations-page.spec.tsx b/web/app/components/tools/__tests__/integrations-page.spec.tsx
index 97bed53f85..9dc647786f 100644
--- a/web/app/components/tools/__tests__/integrations-page.spec.tsx
+++ b/web/app/components/tools/__tests__/integrations-page.spec.tsx
@@ -8,14 +8,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
onSearchTextChange,
searchText,
}: {
- onSearchTextChange: (value: string) => void
+ onSearchTextChange?: (value: string) => void
searchText: string
}) => (
onSearchTextChange(event.target.value)}
+ onChange={event => onSearchTextChange?.(event.target.value)}
/>
),
@@ -33,11 +33,16 @@ vi.mock('@/app/components/header/account-setting/api-based-extension-page', () =
vi.mock('../provider-list', () => ({
__esModule: true,
- default: () => ,
+ default: ({ category }: { category?: string }) => {category}
,
}))
-const renderIntegrationsPage = (searchParams?: Record) => {
- return renderWithNuqs(, { searchParams })
+vi.mock('../plugin-category-page', () => ({
+ __esModule: true,
+ default: ({ category }: { category: string }) => ,
+}))
+
+const renderIntegrationsPage = (searchParams?: Record, section?: React.ComponentProps['section']) => {
+ return renderWithNuqs(, { searchParams })
}
describe('IntegrationsPage', () => {
@@ -60,6 +65,26 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('textbox', { name: 'search' })).toBeInTheDocument()
})
+ it('renders plugin category sections from the section query', () => {
+ const triggerView = renderIntegrationsPage({ section: 'trigger' })
+
+ expect(screen.getByTestId('plugin-category-trigger')).toBeInTheDocument()
+ expect(screen.getByTestId('plugin-category-trigger').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden')
+ expect(screen.getByRole('link', { name: 'common.settings.trigger' })).toHaveAttribute('href', '/integrations/trigger')
+
+ triggerView.unmount()
+ const agentStrategyView = renderIntegrationsPage({ section: 'agent-strategy' })
+
+ expect(screen.getByTestId('plugin-category-agent-strategy')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'common.settings.agentStrategy' })).toHaveAttribute('href', '/integrations/agent-strategy')
+
+ agentStrategyView.unmount()
+ renderIntegrationsPage({ section: 'extension' })
+
+ expect(screen.getByTestId('plugin-category-extension')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'common.settings.extension' })).toHaveAttribute('href', '/integrations/extension')
+ })
+
it('renders migrated legacy setting sections', () => {
const { unmount } = renderIntegrationsPage({ section: 'data-source' })
@@ -71,11 +96,29 @@ describe('IntegrationsPage', () => {
expect(screen.getByTestId('api-extension-page')).toBeInTheDocument()
})
+ it('renders existing pages from route sections', () => {
+ const modelProviderView = renderIntegrationsPage(undefined, 'provider')
+
+ expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
+
+ modelProviderView.unmount()
+ const mcpView = renderIntegrationsPage(undefined, 'mcp')
+
+ expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp')
+ expect(screen.getByTestId('tool-provider-list').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden')
+
+ mcpView.unmount()
+ renderIntegrationsPage(undefined, 'data-source')
+
+ expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
+ })
+
it('keeps existing category-only tools URLs functional', () => {
renderIntegrationsPage({ category: 'mcp' })
expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active')
+ expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('href', '/integrations/tools/mcp')
})
it('collapses and expands the integrations sidebar', () => {
@@ -84,14 +127,14 @@ describe('IntegrationsPage', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.settings.collapse' }))
expect(screen.queryByText('common.settings.integrations')).not.toBeInTheDocument()
- expect(screen.queryByText('common.settings.customTool')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.settings.swaggerAPIAsTool')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument()
- expect(screen.getByLabelText('common.settings.trigger')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'common.settings.trigger' })).toHaveAttribute('href', '/integrations/trigger')
expect(screen.getByRole('button', { name: 'common.settings.expand' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.settings.expand' }))
expect(screen.getByText('common.settings.integrations')).toBeInTheDocument()
- expect(screen.getByText('common.settings.customTool')).toBeInTheDocument()
+ expect(screen.getByText('common.settings.swaggerAPIAsTool')).toBeInTheDocument()
})
})
diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx
index 6661c26083..ffa8507a27 100644
--- a/web/app/components/tools/__tests__/provider-list.spec.tsx
+++ b/web/app/components/tools/__tests__/provider-list.spec.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from 'react'
+import type { ComponentProps, ReactNode } from 'react'
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
@@ -7,6 +7,16 @@ import { ToolTypeEnum } from '../../workflow/block-selector/types'
import ProviderList from '../provider-list'
import { getToolType } from '../utils'
+const { mockRouterPush } = vi.hoisted(() => ({
+ mockRouterPush: vi.fn(),
+}))
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({
+ push: mockRouterPush,
+ }),
+}))
+
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [],
@@ -203,7 +213,7 @@ describe('getToolType', () => {
})
})
-const renderProviderList = (searchParams?: Record) => {
+const renderProviderList = (searchParams?: Record, category?: ComponentProps['category']) => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { enable_marketplace: mockEnableMarketplace },
})
@@ -211,7 +221,7 @@ const renderProviderList = (searchParams?: Record) => {
{children}
)
return renderWithNuqs(
- ,
+ ,
{ searchParams },
)
}
@@ -244,6 +254,16 @@ describe('ProviderList', () => {
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
+ it('uses canonical integration routes when controlled by route category', () => {
+ renderProviderList(undefined, 'mcp')
+
+ expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('tools.type.workflow'))
+
+ expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/workflow')
+ })
+
it('resets current provider when switching to a different tab', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('card-google-search'))
diff --git a/web/app/components/tools/integration-routes.ts b/web/app/components/tools/integration-routes.ts
new file mode 100644
index 0000000000..28291ce3fd
--- /dev/null
+++ b/web/app/components/tools/integration-routes.ts
@@ -0,0 +1,134 @@
+export const INTEGRATION_SECTION_VALUES = [
+ 'provider',
+ 'builtin',
+ 'mcp',
+ 'custom-tool',
+ 'workflow-tool',
+ 'data-source',
+ 'api-based-extension',
+ 'trigger',
+ 'agent-strategy',
+ 'extension',
+] as const
+
+export type IntegrationSection = typeof INTEGRATION_SECTION_VALUES[number]
+
+export const TOOL_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const
+export type ToolCategory = typeof TOOL_CATEGORY_VALUES[number]
+
+export type LegacyToolsSearchParams = Record
+
+const integrationSectionSet = new Set(INTEGRATION_SECTION_VALUES)
+const toolCategorySet = new Set(TOOL_CATEGORY_VALUES)
+
+const isIntegrationSection = (value: string): value is IntegrationSection => {
+ return integrationSectionSet.has(value)
+}
+
+const isToolCategory = (value: string): value is ToolCategory => {
+ return toolCategorySet.has(value)
+}
+
+const getFirstSearchParamValue = (value: string | string[] | undefined) => {
+ if (Array.isArray(value))
+ return value[0]
+
+ return value
+}
+
+export const toolCategoryBySection: Partial> = {
+ 'builtin': 'builtin',
+ 'mcp': 'mcp',
+ 'custom-tool': 'api',
+ 'workflow-tool': 'workflow',
+}
+
+export const sectionByToolCategory: Record = {
+ builtin: 'builtin',
+ api: 'custom-tool',
+ workflow: 'workflow-tool',
+ mcp: 'mcp',
+}
+
+export const integrationPathBySection: Record = {
+ 'provider': '/integrations/model-provider',
+ 'builtin': '/integrations/tools/built-in',
+ 'custom-tool': '/integrations/tools/swagger-api',
+ 'workflow-tool': '/integrations/tools/workflow',
+ 'mcp': '/integrations/tools/mcp',
+ 'data-source': '/integrations/data-source',
+ 'api-based-extension': '/integrations/tools/api-extension',
+ 'trigger': '/integrations/trigger',
+ 'agent-strategy': '/integrations/agent-strategy',
+ 'extension': '/integrations/extension',
+}
+
+export const buildIntegrationPath = (section: IntegrationSection) => {
+ return integrationPathBySection[section]
+}
+
+export const getIntegrationRedirectPathByLegacyToolsSearchParams = (
+ searchParams: LegacyToolsSearchParams = {},
+) => {
+ const sectionParam = getFirstSearchParamValue(searchParams.section)
+ const categoryParam = getFirstSearchParamValue(searchParams.category)
+ const section = sectionParam && isIntegrationSection(sectionParam)
+ ? sectionParam
+ : categoryParam && isToolCategory(categoryParam)
+ ? sectionByToolCategory[categoryParam]
+ : 'builtin'
+
+ const preservedSearchParams = new URLSearchParams()
+ Object.entries(searchParams).forEach(([key, value]) => {
+ if (key === 'section' || key === 'category' || value === undefined)
+ return
+
+ if (Array.isArray(value)) {
+ value.forEach(item => preservedSearchParams.append(key, item))
+ return
+ }
+
+ preservedSearchParams.set(key, value)
+ })
+
+ const query = preservedSearchParams.toString()
+ return query ? `${buildIntegrationPath(section)}?${query}` : buildIntegrationPath(section)
+}
+
+type IntegrationRouteTarget
+ = | { type: 'redirect', destination: string }
+ | { type: 'section', section: IntegrationSection }
+ | { type: 'not-found' }
+
+export const getIntegrationRouteTargetBySlug = (slug?: string[]): IntegrationRouteTarget => {
+ const path = slug?.join('/') ?? ''
+
+ switch (path) {
+ case '':
+ return { type: 'redirect', destination: buildIntegrationPath('provider') }
+ case 'model-provider':
+ return { type: 'section', section: 'provider' }
+ case 'tools':
+ return { type: 'redirect', destination: buildIntegrationPath('builtin') }
+ case 'tools/built-in':
+ return { type: 'section', section: 'builtin' }
+ case 'tools/swagger-api':
+ return { type: 'section', section: 'custom-tool' }
+ case 'tools/workflow':
+ return { type: 'section', section: 'workflow-tool' }
+ case 'tools/mcp':
+ return { type: 'section', section: 'mcp' }
+ case 'data-source':
+ return { type: 'section', section: 'data-source' }
+ case 'tools/api-extension':
+ return { type: 'section', section: 'api-based-extension' }
+ case 'trigger':
+ return { type: 'section', section: 'trigger' }
+ case 'agent-strategy':
+ return { type: 'section', section: 'agent-strategy' }
+ case 'extension':
+ return { type: 'section', section: 'extension' }
+ default:
+ return { type: 'not-found' }
+ }
+}
diff --git a/web/app/components/tools/integration-section-renderer.tsx b/web/app/components/tools/integration-section-renderer.tsx
new file mode 100644
index 0000000000..e79f5688a8
--- /dev/null
+++ b/web/app/components/tools/integration-section-renderer.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import type { IntegrationSection } from './integration-routes'
+import ApiBasedExtensionPage from '@/app/components/header/account-setting/api-based-extension-page'
+import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new'
+import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page'
+import { PluginCategoryEnum } from '@/app/components/plugins/types'
+import PluginCategoryPage from './plugin-category-page'
+import ToolProviderList from './provider-list'
+
+type IntegrationSectionRendererProps = {
+ providerSearchText: string
+ section: IntegrationSection
+}
+
+const IntegrationSectionRenderer = ({
+ providerSearchText,
+ section,
+}: IntegrationSectionRendererProps) => {
+ switch (section) {
+ case 'provider':
+ return (
+
+
+
+ )
+ case 'builtin':
+ return
+ case 'mcp':
+ return
+ case 'custom-tool':
+ return
+ case 'workflow-tool':
+ return
+ case 'data-source':
+ return (
+
+
+
+ )
+ case 'api-based-extension':
+ return (
+
+ )
+ case 'trigger':
+ return
+ case 'agent-strategy':
+ return
+ case 'extension':
+ return
+ default:
+ return null
+ }
+}
+
+export default IntegrationSectionRenderer
diff --git a/web/app/components/tools/integrations-page.tsx b/web/app/components/tools/integrations-page.tsx
index 975b58bdd3..d5f1582b7c 100644
--- a/web/app/components/tools/integrations-page.tsx
+++ b/web/app/components/tools/integrations-page.tsx
@@ -1,48 +1,26 @@
'use client'
+import type { IntegrationSection } from '@/app/components/tools/integration-routes'
import { cn } from '@langgenius/dify-ui/cn'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DatasourceIcon from '@/app/components/base/icons/src/vender/workflow/Datasource'
-import ApiBasedExtensionPage from '@/app/components/header/account-setting/api-based-extension-page'
-import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new'
-import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page'
+import {
+ buildIntegrationPath,
+ INTEGRATION_SECTION_VALUES,
+ sectionByToolCategory,
+ TOOL_CATEGORY_VALUES,
+ toolCategoryBySection,
+} from '@/app/components/tools/integration-routes'
import Link from '@/next/link'
-import ToolProviderList from './provider-list'
+import IntegrationSectionRenderer from './integration-section-renderer'
-const INTEGRATION_SECTION_VALUES = [
- 'provider',
- 'builtin',
- 'mcp',
- 'custom-tool',
- 'workflow-tool',
- 'data-source',
- 'api-based-extension',
-] as const
-
-type IntegrationSection = typeof INTEGRATION_SECTION_VALUES[number]
-
-const TOOL_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const
-type ToolCategory = typeof TOOL_CATEGORY_VALUES[number]
type IconComponent = typeof DatasourceIcon
const parseAsIntegrationSection = parseAsStringLiteral(INTEGRATION_SECTION_VALUES)
const parseAsToolCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES)
-const toolCategoryBySection: Partial> = {
- 'builtin': 'builtin',
- 'mcp': 'mcp',
- 'custom-tool': 'api',
- 'workflow-tool': 'workflow',
-}
-const sectionByToolCategory: Record = {
- builtin: 'builtin',
- api: 'custom-tool',
- workflow: 'workflow-tool',
- mcp: 'mcp',
-}
-
type NavItem = {
activeIcon?: IconComponent | string
disabled?: boolean
@@ -58,12 +36,7 @@ const inactiveNavItemClassName = 'text-components-menu-item-text hover:bg-state-
const disabledNavItemClassName = 'cursor-not-allowed text-components-menu-item-text-disabled'
const buildSectionHref = (section: IntegrationSection) => {
- const category = toolCategoryBySection[section]
- const params = new URLSearchParams({ section })
- if (category)
- params.set('category', category)
-
- return `/tools?${params.toString()}`
+ return buildIntegrationPath(section)
}
type NavLinkItemProps = {
@@ -125,12 +98,18 @@ const NavLinkItem = ({ collapsed, item, section }: NavLinkItemProps) => {
)
}
-export default function IntegrationsPage() {
+type IntegrationsPageProps = {
+ section?: IntegrationSection
+}
+
+export default function IntegrationsPage({
+ section: routeSection,
+}: IntegrationsPageProps) {
const { t } = useTranslation()
const [sectionParam] = useQueryState('section', parseAsIntegrationSection)
const [categoryParam] = useQueryState('category', parseAsToolCategory)
- const section = sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider')
- const [providerSearchText, setProviderSearchText] = useState('')
+ const section = routeSection ?? sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider')
+ const providerSearchText = ''
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const providerItem = useMemo(() => ({
section: 'provider',
@@ -147,13 +126,13 @@ export default function IntegrationsPage() {
},
{
section: 'custom-tool',
- label: t('settings.customTool', { ns: 'common' }),
+ label: t('settings.swaggerAPIAsTool', { ns: 'common' }),
icon: 'i-custom-vender-integrations-custom-tool',
iconClassName: 'h-[14.5px] w-[12.5px]',
},
{
section: 'workflow-tool',
- label: t('type.workflow', { ns: 'tools' }),
+ label: t('common.workflowAsTool', { ns: 'workflow' }),
icon: 'i-custom-vender-integrations-workflow-as-tool',
iconClassName: 'h-3 w-[12.5px]',
},
@@ -172,26 +151,28 @@ export default function IntegrationsPage() {
iconClassName: 'size-4',
},
{
+ section: 'trigger',
label: t('settings.trigger', { ns: 'common' }),
icon: 'i-custom-vender-integrations-trigger',
iconClassName: 'h-[13.5px] w-[13.5px]',
- disabled: true,
},
{
+ section: 'agent-strategy',
label: t('settings.agentStrategy', { ns: 'common' }),
icon: 'i-custom-vender-integrations-agent-strategy',
iconClassName: 'h-[14.5px] w-[15.5px]',
- disabled: true,
},
{
+ section: 'extension',
label: t('settings.extension', { ns: 'common' }),
icon: 'i-custom-vender-integrations-extension',
iconClassName: 'h-[13.5px] w-3',
- disabled: true,
},
], [t])
const activeItem = [providerItem, ...toolItems, ...secondaryItems].find(item => item.section === section)
const isToolSection = Boolean(toolCategoryBySection[section])
+ const isPluginCategorySection = section === 'trigger' || section === 'agent-strategy' || section === 'extension'
+ const useFillLayout = isToolSection || isPluginCategorySection
return (
@@ -342,26 +323,15 @@ export default function IntegrationsPage() {
)}
-
- {section === 'provider' && (
-
-
-
- )}
- {section === 'data-source' && (
-
-
-
- )}
- {section === 'api-based-extension' && (
-
- )}
- {isToolSection &&
}
+
+
diff --git a/web/app/components/tools/plugin-category-page.tsx b/web/app/components/tools/plugin-category-page.tsx
new file mode 100644
index 0000000000..2323ce8ad0
--- /dev/null
+++ b/web/app/components/tools/plugin-category-page.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import type { PluginCategoryEnum } from '@/app/components/plugins/types'
+import { useMemo } from 'react'
+import { PluginPageContextProvider } from '@/app/components/plugins/plugin-page/context-provider'
+import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
+
+type PluginCategoryPageProps = {
+ category: PluginCategoryEnum
+}
+
+const PluginCategoryPage = ({
+ category,
+}: PluginCategoryPageProps) => {
+ const initialFilters = useMemo(() => ({
+ categories: [category],
+ tags: [],
+ searchQuery: '',
+ }), [category])
+
+ return (
+
+
+
+ )
+}
+
+export default PluginCategoryPage
diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx
index 74fc47a833..e4210f94b2 100644
--- a/web/app/components/tools/provider-list.tsx
+++ b/web/app/components/tools/provider-list.tsx
@@ -1,5 +1,6 @@
'use client'
import type { Collection } from './types'
+import type { ToolCategory } from '@/app/components/tools/integration-routes'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
@@ -12,10 +13,12 @@ import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import Empty from '@/app/components/plugins/marketplace/empty'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
+import { buildIntegrationPath, sectionByToolCategory, TOOL_CATEGORY_VALUES } from '@/app/components/tools/integration-routes'
import LabelFilter from '@/app/components/tools/labels/filter'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
+import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
@@ -24,21 +27,26 @@ import { useMarketplace } from './marketplace/hooks'
import MCPList from './mcp'
import { getToolType } from './utils'
-const TOOL_PROVIDER_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const
-type ToolProviderCategory = typeof TOOL_PROVIDER_CATEGORY_VALUES[number]
-const toolProviderCategorySet = new Set
(TOOL_PROVIDER_CATEGORY_VALUES)
+const toolProviderCategorySet = new Set(TOOL_CATEGORY_VALUES)
-const isToolProviderCategory = (value: string): value is ToolProviderCategory => {
+const isToolProviderCategory = (value: string): value is ToolCategory => {
return toolProviderCategorySet.has(value)
}
-const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_PROVIDER_CATEGORY_VALUES)
+const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES)
.withDefault('builtin')
-const ProviderList = () => {
+type ProviderListProps = {
+ category?: ToolCategory
+}
+
+const ProviderList = ({
+ category,
+}: ProviderListProps) => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'
const { t } = useTranslation()
+ const router = useRouter()
const { getTagLabel } = useTags()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@@ -46,7 +54,8 @@ const ProviderList = () => {
})
const containerRef = useRef(null)
- const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory)
+ const [categoryParam, setCategoryParam] = useQueryState('category', parseAsToolProviderCategory)
+ const activeTab = category ?? categoryParam
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
@@ -139,7 +148,11 @@ const ProviderList = () => {
onChange={(state) => {
if (!isToolProviderCategory(state))
return
- setActiveTab(state)
+ if (category)
+ router.push(buildIntegrationPath(sectionByToolCategory[state]))
+ else
+ setCategoryParam(state)
+
if (state !== activeTab)
setCurrentProviderId(undefined)
}}
diff --git a/web/app/components/tools/provider/__tests__/empty.spec.tsx b/web/app/components/tools/provider/__tests__/empty.spec.tsx
index 7484f99895..0a4e852e7f 100644
--- a/web/app/components/tools/provider/__tests__/empty.spec.tsx
+++ b/web/app/components/tools/provider/__tests__/empty.spec.tsx
@@ -42,18 +42,18 @@ describe('Empty', () => {
// Tests for different type prop values
describe('Type Props', () => {
- it('should render with Custom type and include link to /tools?category=api', () => {
+ it('should render with Custom type and include link to Swagger API as Tool', () => {
render()
- const link = document.querySelector('a[href="/tools?category=api"]')
+ const link = document.querySelector('a[href="/integrations/tools/swagger-api"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
})
- it('should render with MCP type and include link to /tools?category=mcp', () => {
+ it('should render with MCP type and include link to the MCP route', () => {
render()
- const link = document.querySelector('a[href="/tools?category=mcp"]')
+ const link = document.querySelector('a[href="/integrations/tools/mcp"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
})
diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx
index ca64cf0da0..e74beb5c63 100644
--- a/web/app/components/tools/provider/empty.tsx
+++ b/web/app/components/tools/provider/empty.tsx
@@ -2,6 +2,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
+import { buildIntegrationPath } from '@/app/components/tools/integration-routes'
import useTheme from '@/hooks/use-theme'
import Link from '@/next/link'
import { NoToolPlaceholder } from '../../base/icons/src/vender/other'
@@ -15,11 +16,11 @@ type Props = {
const getLink = (type?: ToolTypeEnum) => {
switch (type) {
case ToolTypeEnum.Custom:
- return '/tools?category=api'
+ return buildIntegrationPath('custom-tool')
case ToolTypeEnum.MCP:
- return '/tools?category=mcp'
+ return buildIntegrationPath('mcp')
default:
- return '/tools?category=api'
+ return buildIntegrationPath('custom-tool')
}
}
const Empty = ({
diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
index d8babd2955..27f6788ce7 100644
--- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
+++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
@@ -399,7 +399,7 @@ describe('WorkflowToolConfigureButton', () => {
await user.click(screen.getByText('workflow.common.manageInTools'))
// Assert
- expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
+ expect(mockPush).toHaveBeenCalledWith('/integrations/tools/workflow')
})
})
diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx
index 370154aa57..04a39046f8 100644
--- a/web/app/components/tools/workflow-tool/configure-button.tsx
+++ b/web/app/components/tools/workflow-tool/configure-button.tsx
@@ -4,6 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
+import { buildIntegrationPath } from '@/app/components/tools/integration-routes'
import { useRouter } from '@/next/navigation'
import Divider from '../../base/divider'
@@ -95,7 +96,7 @@ const WorkflowToolConfigureButton = ({