mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
feat: add canonical integrations routes
This commit is contained in:
parent
15292ba527
commit
10cc0ddb64
140
docs/integrations-route-contract.md
Normal file
140
docs/integrations-route-contract.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Integrations Route Contract
|
||||
|
||||
This document records the current canonical routes for the Integrations navigation, the legacy routes that redirect to them, and the remaining migration gaps. The first migration only moves existing pages onto the new routes; UI redesign work is out of scope.
|
||||
|
||||
## Current Status
|
||||
|
||||
Completed:
|
||||
|
||||
| Area | Status |
|
||||
| --- | --- |
|
||||
| Canonical `/integrations/...` route adapter | Implemented in `web/app/(commonLayout)/integrations/[[...slug]]/page.tsx`. |
|
||||
| Route contract utility | Implemented in `web/app/components/tools/integration-routes.ts`. |
|
||||
| Existing page reuse | Implemented through `IntegrationSectionRenderer`; no duplicated UI copy. |
|
||||
| Legacy `/tools?...` redirects | Implemented through `web/app/(commonLayout)/tools/page.tsx`. |
|
||||
| Legacy `/plugins` installed redirects | Implemented through `web/app/(commonLayout)/plugins/page.tsx`. |
|
||||
| Tools tab navigation under new URLs | Implemented; scoped tool tabs push canonical `/integrations/tools/...` URLs. |
|
||||
| Singular-only canonical URLs | Implemented; plural and misplaced aliases are intentionally unsupported. |
|
||||
|
||||
Not completed:
|
||||
|
||||
| Area | Remaining work |
|
||||
| --- | --- |
|
||||
| Integrations overview page | Not introduced; `/integrations` currently redirects to `/integrations/model-provider`. |
|
||||
| Tools overview page | Not introduced; `/integrations/tools` currently redirects to `/integrations/tools/built-in`. |
|
||||
| Plugin route migration | `/plugins` still owns the old plugin management and marketplace surface; no `/integrations/plugin` route will be introduced. Non-marketplace plugin URLs should redirect to `/integrations`. |
|
||||
| Marketplace route migration | `/marketplace/...` routes below are future recommendations only; they are not implemented here. |
|
||||
| New onboarding UI redesign | Not started in this route migration; current pages intentionally reuse existing UI. |
|
||||
| Marketplace plugin redirects | Not implemented; marketplace plugin URLs intentionally keep rendering the legacy plugin marketplace surface for now. |
|
||||
|
||||
## Navigation Labels
|
||||
|
||||
| Navigation item | Canonical label |
|
||||
| --- | --- |
|
||||
| Model Provider | Model Provider |
|
||||
| Built-in tools | Built-in |
|
||||
| Custom Tool | Swagger API as Tool |
|
||||
| Workflow | Workflow as Tool |
|
||||
| MCP | MCP |
|
||||
| Data Source | Data Source |
|
||||
| API Extension | API Extension |
|
||||
| Plugins | Plugins |
|
||||
| Marketplace | Marketplace |
|
||||
|
||||
## Canonical Integrations Routes
|
||||
|
||||
| Route | Destination |
|
||||
| --- | --- |
|
||||
| `/integrations` | Redirect to `/integrations/model-provider` unless an overview page is introduced. |
|
||||
| `/integrations/model-provider` | Existing model provider management page. |
|
||||
| `/integrations/tools` | Redirect to `/integrations/tools/built-in` unless a tools overview page is introduced. |
|
||||
| `/integrations/tools/built-in` | Existing built-in tools list. |
|
||||
| `/integrations/tools/swagger-api` | Existing custom API tool list, relabeled as Swagger API as Tool. |
|
||||
| `/integrations/tools/workflow` | Existing Workflow as Tool management page. |
|
||||
| `/integrations/tools/mcp` | Existing MCP tools management page. |
|
||||
| `/integrations/trigger` | Existing plugin trigger list filtered from plugin management. |
|
||||
| `/integrations/agent-strategy` | Existing agent strategy plugin list filtered from plugin management. |
|
||||
| `/integrations/extension` | Existing extension plugin list filtered from plugin management. |
|
||||
| `/integrations/data-source` | Existing data source page. |
|
||||
| `/integrations/tools/api-extension` | Existing API extension page under the Tools group. |
|
||||
|
||||
## Integration Plugin Category Routes
|
||||
|
||||
These navigation items use plugin categories from the existing plugin management surface:
|
||||
|
||||
| Navigation item | Plugin category | Route |
|
||||
| --- | --- | --- |
|
||||
| Trigger | `trigger` | `/integrations/trigger` |
|
||||
| Agent Strategy | `agent-strategy` | `/integrations/agent-strategy` |
|
||||
| Extension | `extension` | `/integrations/extension` |
|
||||
|
||||
The install and filter controls in the Integrations sidebar are disabled actions, not route destinations.
|
||||
|
||||
These routes reuse the installed plugin management list with an initial category filter. They are not marketplace category pages.
|
||||
|
||||
Do not treat every plugin category as an Integrations navigation item automatically. `trigger`, `agent-strategy`, and `extension` are currently exposed under Integrations because they are explicit navigation items. Other plugin categories have different product meanings:
|
||||
|
||||
| Plugin category | Integrations relationship |
|
||||
| --- | --- |
|
||||
| `tool` | Not equal to `/integrations/tools/...`; tool plugins can expose tool providers that appear in Tools, but the Tools page is provider-based. |
|
||||
| `model` | Not equal to `/integrations/model-provider`; model providers are managed through the model provider page. |
|
||||
| `datasource` | Not equal to the full Data Source page; data source integrations have their own existing page. |
|
||||
| `trigger` | Reused as `/integrations/trigger`, installed plugins filtered by category. |
|
||||
| `agent-strategy` | Reused as `/integrations/agent-strategy`, installed plugins filtered by category. |
|
||||
| `extension` | Reused as `/integrations/extension`, installed plugins filtered by category. |
|
||||
|
||||
## Legacy Tools Redirects
|
||||
|
||||
| Legacy route | New route |
|
||||
| --- | --- |
|
||||
| `/tools` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=provider` | `/integrations/model-provider` |
|
||||
| `/tools?section=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=builtin&category=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?category=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=custom-tool` | `/integrations/tools/swagger-api` |
|
||||
| `/tools?section=custom-tool&category=api` | `/integrations/tools/swagger-api` |
|
||||
| `/tools?category=api` | `/integrations/tools/swagger-api` |
|
||||
| `/tools?section=workflow-tool` | `/integrations/tools/workflow` |
|
||||
| `/tools?section=workflow-tool&category=workflow` | `/integrations/tools/workflow` |
|
||||
| `/tools?category=workflow` | `/integrations/tools/workflow` |
|
||||
| `/tools?section=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?section=mcp&category=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?category=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?section=data-source` | `/integrations/data-source` |
|
||||
| `/tools?section=api-based-extension` | `/integrations/tools/api-extension` |
|
||||
| `/tools?section=trigger` | `/integrations/trigger` |
|
||||
| `/tools?section=agent-strategy` | `/integrations/agent-strategy` |
|
||||
| `/tools?section=extension` | `/integrations/extension` |
|
||||
|
||||
Preserve non-routing query parameters such as `q`, `tags`, and `sort`, but drop legacy routing parameters such as `section` and `category` during redirects.
|
||||
|
||||
## Non-Canonical Integrations Routes
|
||||
|
||||
Do not add plural or misplaced alias redirects for new Integrations URLs. Only the singular canonical routes above should resolve. For example, `/integrations/model-providers`, `/integrations/data-sources`, `/integrations/api-extensions`, `/integrations/tools/trigger`, `/integrations/tools/agent-strategy`, and `/integrations/tools/extension` should not be treated as supported URLs unless they are later confirmed to have shipped externally.
|
||||
|
||||
## Legacy Plugin Redirects
|
||||
|
||||
Plugins have two different product meanings today: installed plugin management and marketplace discovery. Only the non-marketplace plugin URLs should redirect into Integrations. There is no `/integrations/plugin` route.
|
||||
|
||||
| Old Plugin URL | Recommended redirect | Reason |
|
||||
| --- | --- | --- |
|
||||
| `/plugins` | `/integrations` | Installed plugin management entry should move into the Integrations main entry. |
|
||||
| `/plugins?tab=plugins` | `/integrations` | Explicit installed plugins tab; non-marketplace semantics. |
|
||||
| `/plugins?tab=discover` | Do not redirect to Integrations | Marketplace discovery. |
|
||||
| `/plugins?tab=all` | Do not redirect to Integrations | Marketplace category: all. |
|
||||
| `/plugins?tab=tool` | Do not redirect to Integrations | Marketplace tool category, not installed tools management. |
|
||||
| `/plugins?tab=model` | Do not redirect to Integrations | Marketplace model category. |
|
||||
| `/plugins?tab=trigger` | Do not redirect to Integrations | Marketplace trigger category. |
|
||||
| `/plugins?tab=agent-strategy` | Do not redirect to Integrations | Marketplace agent strategy category. |
|
||||
| `/plugins?tab=extension` | Do not redirect to Integrations | Marketplace extension category. |
|
||||
| `/plugins?tab=datasource` | Do not redirect to Integrations | Marketplace datasource category. |
|
||||
| `/plugins?tab=bundle` | Do not redirect to Integrations | Marketplace bundle category. |
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Add the canonical route map and route tests.
|
||||
2. Mount the existing pages under the new Integrations routes without UI redesign.
|
||||
3. Update internal links to generate canonical URLs.
|
||||
4. Add legacy redirects for `/tools` and `/plugins`.
|
||||
5. Keep compatibility tests for each legacy route until old links can be removed.
|
||||
26
web/app/(commonLayout)/integrations/[[...slug]]/page.tsx
Normal file
26
web/app/(commonLayout)/integrations/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { getIntegrationRouteTargetBySlug } from '@/app/components/tools/integration-routes'
|
||||
import IntegrationsPage from '@/app/components/tools/integrations-page'
|
||||
import { notFound, redirect } from '@/next/navigation'
|
||||
|
||||
type IntegrationsRoutePageProps = {
|
||||
params: Promise<{
|
||||
slug?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
const IntegrationsRoutePage = async ({
|
||||
params,
|
||||
}: IntegrationsRoutePageProps) => {
|
||||
const { slug } = await params
|
||||
const target = getIntegrationRouteTargetBySlug(slug)
|
||||
|
||||
if (target.type === 'redirect')
|
||||
redirect(target.destination)
|
||||
|
||||
if (target.type === 'not-found')
|
||||
notFound()
|
||||
|
||||
return <IntegrationsPage section={target.section} />
|
||||
}
|
||||
|
||||
export default IntegrationsRoutePage
|
||||
@ -1,8 +1,22 @@
|
||||
import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-routes'
|
||||
import Marketplace from '@/app/components/plugins/marketplace'
|
||||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
|
||||
import { getLegacyPluginRedirectPath } from '@/app/components/plugins/plugin-routes'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
type PluginListProps = {
|
||||
searchParams?: Promise<LegacyPluginsSearchParams>
|
||||
}
|
||||
|
||||
const PluginList = async ({
|
||||
searchParams,
|
||||
}: PluginListProps) => {
|
||||
const redirectPath = getLegacyPluginRedirectPath(await searchParams)
|
||||
|
||||
if (redirectPath)
|
||||
redirect(redirectPath)
|
||||
|
||||
const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IntegrationsPage from '@/app/components/tools/integrations-page'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import type { LegacyToolsSearchParams } from '@/app/components/tools/integration-routes'
|
||||
import { getIntegrationRedirectPathByLegacyToolsSearchParams } from '@/app/components/tools/integration-routes'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
const ToolsList: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t('menus.tools', { ns: 'common' }))
|
||||
|
||||
return <IntegrationsPage />
|
||||
type ToolsPageProps = {
|
||||
searchParams?: Promise<LegacyToolsSearchParams>
|
||||
}
|
||||
export default React.memo(ToolsList)
|
||||
|
||||
const ToolsPage = async ({
|
||||
searchParams,
|
||||
}: ToolsPageProps) => {
|
||||
const resolvedSearchParams = await searchParams
|
||||
|
||||
redirect(getIntegrationRedirectPathByLegacyToolsSearchParams(resolvedSearchParams))
|
||||
}
|
||||
|
||||
export default ToolsPage
|
||||
|
||||
@ -47,12 +47,12 @@ describe('AccountSetting Constants', () => {
|
||||
|
||||
it('should map migrated setting tabs to integrations sections', () => {
|
||||
expect(enableMovedAccountSettingDestinations).toBe(true)
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/tools?section=provider')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/tools?section=data-source')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/tools?section=api-based-extension')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/tools?section=provider')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/tools?section=data-source')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/tools?section=api-based-extension')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/integrations/model-provider')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/integrations/data-source')
|
||||
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/integrations/tools/api-extension')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/integrations/model-provider')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/integrations/data-source')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/integrations/tools/api-extension')
|
||||
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.BILLING)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { AccountSettingTab } from './constants'
|
||||
import { buildIntegrationPath } from '@/app/components/tools/integration-routes'
|
||||
import { ACCOUNT_SETTING_TAB } from './constants'
|
||||
|
||||
export const movedAccountSettingDestinations: Partial<Record<AccountSettingTab, string>> = {
|
||||
[ACCOUNT_SETTING_TAB.PROVIDER]: '/tools?section=provider',
|
||||
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: '/tools?section=data-source',
|
||||
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: '/tools?section=api-based-extension',
|
||||
[ACCOUNT_SETTING_TAB.PROVIDER]: buildIntegrationPath('provider'),
|
||||
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: buildIntegrationPath('data-source'),
|
||||
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: buildIntegrationPath('api-based-extension'),
|
||||
}
|
||||
|
||||
export const enableMovedAccountSettingDestinations = true
|
||||
|
||||
@ -102,7 +102,7 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
|
||||
describe('ModelProviderPage non-cloud branch', () => {
|
||||
it('should skip the quota panel when cloud edition is disabled', () => {
|
||||
renderWithSystemFeatures(<ModelProviderPage searchText="" onSearchTextChange={vi.fn()} />, {
|
||||
renderWithSystemFeatures(<ModelProviderPage searchText="" />, {
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
})
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ const renderModelProviderPage = (
|
||||
props: { searchText?: string, enableMarketplace?: boolean } = {},
|
||||
) => {
|
||||
const { searchText = '', enableMarketplace = true } = props
|
||||
return renderWithSystemFeatures(<ModelProviderPage searchText={searchText} onSearchTextChange={vi.fn()} />, {
|
||||
return renderWithSystemFeatures(<ModelProviderPage searchText={searchText} />, {
|
||||
systemFeatures: { enable_marketplace: enableMarketplace },
|
||||
})
|
||||
}
|
||||
@ -71,17 +71,6 @@ vi.mock('../install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
default: () => ({
|
||||
referenceSetting: { permission: {}, auto_upgrade: {} },
|
||||
setReferenceSettings: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/reference-setting-modal', () => ({
|
||||
default: () => <div data-testid="reference-setting-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card', () => ({
|
||||
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
|
||||
}))
|
||||
@ -158,9 +147,8 @@ describe('ModelProviderPage', () => {
|
||||
|
||||
it('should render main elements', () => {
|
||||
renderModelProviderPage()
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -5,12 +5,9 @@ import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -25,23 +22,21 @@ import {
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import ProviderAddedCard from './provider-added-card'
|
||||
import QuotaPanel from './provider-added-card/quota-panel'
|
||||
import { providerSupportsCredits } from './supports-credits'
|
||||
import SystemModelSelector from './system-model-selector'
|
||||
import { providerToPluginId } from './utils'
|
||||
|
||||
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
|
||||
|
||||
type Props = {
|
||||
onSearchTextChange: (value: string) => void
|
||||
onSearchTextChange?: (value: string) => void
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
|
||||
|
||||
const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const [showUpdateSettingModal, setShowUpdateSettingModal] = useState(false)
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
@ -49,10 +44,6 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { modelProviders: providers } = useProviderContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const {
|
||||
referenceSetting,
|
||||
setReferenceSettings,
|
||||
} = useReferenceSetting()
|
||||
|
||||
const allPluginIds = useMemo(() => {
|
||||
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
|
||||
@ -138,70 +129,36 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
|
||||
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
|
||||
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
|
||||
const [creditsBackedProviders, otherConfiguredProviders] = useMemo(() => {
|
||||
const creditsBackedProviders: ModelProvider[] = []
|
||||
const otherConfiguredProviders: ModelProvider[] = []
|
||||
|
||||
filteredConfiguredProviders.forEach((provider) => {
|
||||
if (providerSupportsCredits(provider, systemFeatures.trial_models))
|
||||
creditsBackedProviders.push(provider)
|
||||
else
|
||||
otherConfiguredProviders.push(provider)
|
||||
})
|
||||
|
||||
return [creditsBackedProviders, otherConfiguredProviders]
|
||||
}, [filteredConfiguredProviders, systemFeatures.trial_models])
|
||||
const hasConfiguredProviders = creditsBackedProviders.length > 0 || otherConfiguredProviders.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative -mt-2 pt-1">
|
||||
<div className="mb-2 flex h-8 items-center justify-between">
|
||||
<SearchInput
|
||||
className="w-[200px]"
|
||||
value={searchText}
|
||||
onChange={onSearchTextChange}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!referenceSetting}
|
||||
className="flex h-8 w-[208px] shrink-0 items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => setShowUpdateSettingModal(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-flashlight-line size-4 shrink-0" />
|
||||
<span className="truncate px-0.5">{t('autoUpdate.updateSettings', { ns: 'plugin' })}</span>
|
||||
<span className="inline-flex h-[18px] min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('autoUpdate.strategy.latest.name', { ns: 'plugin' })}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" />
|
||||
</button>
|
||||
<div className={cn(
|
||||
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
|
||||
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
<div className={cn('mb-2 flex items-center')}>
|
||||
<div className="grow system-md-semibold text-text-primary">{t('modelProvider.models', { ns: 'common' })}</div>
|
||||
<div className={cn(
|
||||
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
|
||||
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
)}
|
||||
>
|
||||
{showWarning && <div className="absolute top-0 right-0 bottom-0 left-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{showWarning && (
|
||||
<div className="flex items-center gap-1 system-xs-medium text-text-primary">
|
||||
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
|
||||
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{showWarning && <div className="absolute top-0 right-0 bottom-0 left-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{showWarning && (
|
||||
<div className="flex items-center gap-1 system-xs-medium text-text-primary">
|
||||
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
|
||||
<span className="max-w-[280px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<SystemModelSelector
|
||||
className="w-[188px]"
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
/>
|
||||
</div>
|
||||
<SystemModelSelector
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
|
||||
{!hasConfiguredProviders && (
|
||||
{!filteredConfiguredProviders?.length && (
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
|
||||
<span className="i-ri-brain-line h-5 w-5 text-text-primary" />
|
||||
@ -210,46 +167,23 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{!!creditsBackedProviders.length && (
|
||||
<section className="pt-2">
|
||||
<div className="mb-2">
|
||||
<div className="system-md-semibold text-text-primary">{t('modelProvider.creditsBackedProviders', { ns: 'common' })}</div>
|
||||
<div className="mt-0.5 system-xs-regular text-text-tertiary">{t('modelProvider.creditsBackedProvidersDesc', { ns: 'common' })}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{creditsBackedProviders.map(provider => (
|
||||
<ProviderAddedCard
|
||||
layout="grid"
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{!!otherConfiguredProviders.length && (
|
||||
<section className="pt-4">
|
||||
<div className="mb-2 system-md-semibold text-text-primary">{t('modelProvider.configuredProviders', { ns: 'common' })}</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{otherConfiguredProviders.map(provider => (
|
||||
<ProviderAddedCard
|
||||
layout="grid"
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{!!filteredConfiguredProviders?.length && (
|
||||
<div className="relative">
|
||||
{filteredConfiguredProviders?.map(provider => (
|
||||
<ProviderAddedCard
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!!filteredNotConfiguredProviders?.length && (
|
||||
<section className="pt-4">
|
||||
<div className="mb-2 flex items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<>
|
||||
<div className="mb-2 flex items-center pt-2 system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||
<div className="relative">
|
||||
{filteredNotConfiguredProviders?.map(provider => (
|
||||
<ProviderAddedCard
|
||||
layout="grid"
|
||||
notConfigured
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
@ -257,7 +191,7 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
enableMarketplace && (
|
||||
@ -267,13 +201,6 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
{showUpdateSettingModal && referenceSetting && (
|
||||
<ReferenceSettingModal
|
||||
payload={referenceSetting}
|
||||
onHide={() => setShowUpdateSettingModal(false)}
|
||||
onSave={setReferenceSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ describe('ToolsNav', () => {
|
||||
render(<ToolsNav />)
|
||||
|
||||
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(<ToolsNav />)
|
||||
|
||||
expect(screen.getByRole('link')).toHaveClass('bg-components-main-nav-nav-button-bg-active')
|
||||
expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
|
||||
@ -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 (
|
||||
<Link
|
||||
href="/tools"
|
||||
href={buildIntegrationPath('builtin')}
|
||||
className={cn('group text-sm font-medium', activated && 'hover:bg-components-main-nav-nav-button-bg-active-hover bg-components-main-nav-nav-button-bg-active font-semibold shadow-md', activated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text hover:bg-components-main-nav-nav-button-bg-hover', className)}
|
||||
>
|
||||
{
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
26
web/app/components/plugins/__tests__/plugin-routes.spec.ts
Normal file
26
web/app/components/plugins/__tests__/plugin-routes.spec.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
@ -28,13 +28,15 @@ const parseAsPluginPageTab = parseAsStringEnum<PluginPageTab>(PLUGIN_PAGE_TAB_VA
|
||||
|
||||
type PluginPageContextProviderProps = {
|
||||
children: ReactNode
|
||||
initialFilters?: FilterState
|
||||
}
|
||||
|
||||
export const PluginPageContextProvider = ({
|
||||
children,
|
||||
initialFilters,
|
||||
}: PluginPageContextProviderProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
const [filters, setFilters] = useState<FilterState>(initialFilters ?? {
|
||||
categories: [],
|
||||
tags: [],
|
||||
searchQuery: '',
|
||||
|
||||
32
web/app/components/plugins/plugin-routes.ts
Normal file
32
web/app/components/plugins/plugin-routes.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './marketplace/constants'
|
||||
|
||||
export type LegacyPluginsSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
const INSTALLED_PLUGINS_TAB = 'plugins'
|
||||
const MARKETPLACE_TAB = 'discover'
|
||||
|
||||
const marketplacePluginTabs = new Set<string>([
|
||||
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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}) => (
|
||||
<div data-testid="model-provider-page">
|
||||
<input
|
||||
aria-label="search"
|
||||
value={searchText}
|
||||
onChange={event => onSearchTextChange(event.target.value)}
|
||||
onChange={event => onSearchTextChange?.(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@ -33,11 +33,16 @@ vi.mock('@/app/components/header/account-setting/api-based-extension-page', () =
|
||||
|
||||
vi.mock('../provider-list', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="tool-provider-list" />,
|
||||
default: ({ category }: { category?: string }) => <div data-testid="tool-provider-list">{category}</div>,
|
||||
}))
|
||||
|
||||
const renderIntegrationsPage = (searchParams?: Record<string, string>) => {
|
||||
return renderWithNuqs(<IntegrationsPage />, { searchParams })
|
||||
vi.mock('../plugin-category-page', () => ({
|
||||
__esModule: true,
|
||||
default: ({ category }: { category: string }) => <div data-testid={`plugin-category-${category}`} />,
|
||||
}))
|
||||
|
||||
const renderIntegrationsPage = (searchParams?: Record<string, string>, section?: React.ComponentProps<typeof IntegrationsPage>['section']) => {
|
||||
return renderWithNuqs(<IntegrationsPage section={section} />, { 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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, string>) => {
|
||||
const renderProviderList = (searchParams?: Record<string, string>, category?: ComponentProps<typeof ProviderList>['category']) => {
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: mockEnableMarketplace },
|
||||
})
|
||||
@ -211,7 +221,7 @@ const renderProviderList = (searchParams?: Record<string, string>) => {
|
||||
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
|
||||
)
|
||||
return renderWithNuqs(
|
||||
<Wrapped><ProviderList /></Wrapped>,
|
||||
<Wrapped><ProviderList category={category} /></Wrapped>,
|
||||
{ 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'))
|
||||
|
||||
134
web/app/components/tools/integration-routes.ts
Normal file
134
web/app/components/tools/integration-routes.ts
Normal file
@ -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<string, string | string[] | undefined>
|
||||
|
||||
const integrationSectionSet = new Set<string>(INTEGRATION_SECTION_VALUES)
|
||||
const toolCategorySet = new Set<string>(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<Record<IntegrationSection, ToolCategory>> = {
|
||||
'builtin': 'builtin',
|
||||
'mcp': 'mcp',
|
||||
'custom-tool': 'api',
|
||||
'workflow-tool': 'workflow',
|
||||
}
|
||||
|
||||
export const sectionByToolCategory: Record<ToolCategory, IntegrationSection> = {
|
||||
builtin: 'builtin',
|
||||
api: 'custom-tool',
|
||||
workflow: 'workflow-tool',
|
||||
mcp: 'mcp',
|
||||
}
|
||||
|
||||
export const integrationPathBySection: Record<IntegrationSection, string> = {
|
||||
'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' }
|
||||
}
|
||||
}
|
||||
58
web/app/components/tools/integration-section-renderer.tsx
Normal file
58
web/app/components/tools/integration-section-renderer.tsx
Normal file
@ -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 (
|
||||
<div className="px-6 pt-6">
|
||||
<ModelProviderPage searchText={providerSearchText} />
|
||||
</div>
|
||||
)
|
||||
case 'builtin':
|
||||
return <ToolProviderList category="builtin" />
|
||||
case 'mcp':
|
||||
return <ToolProviderList category="mcp" />
|
||||
case 'custom-tool':
|
||||
return <ToolProviderList category="api" />
|
||||
case 'workflow-tool':
|
||||
return <ToolProviderList category="workflow" />
|
||||
case 'data-source':
|
||||
return (
|
||||
<div className="px-6 pt-6">
|
||||
<DataSourcePage />
|
||||
</div>
|
||||
)
|
||||
case 'api-based-extension':
|
||||
return (
|
||||
<div className="px-6 pt-6">
|
||||
<ApiBasedExtensionPage />
|
||||
</div>
|
||||
)
|
||||
case 'trigger':
|
||||
return <PluginCategoryPage category={PluginCategoryEnum.trigger} />
|
||||
case 'agent-strategy':
|
||||
return <PluginCategoryPage category={PluginCategoryEnum.agent} />
|
||||
case 'extension':
|
||||
return <PluginCategoryPage category={PluginCategoryEnum.extension} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationSectionRenderer
|
||||
@ -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<Record<IntegrationSection, string>> = {
|
||||
'builtin': 'builtin',
|
||||
'mcp': 'mcp',
|
||||
'custom-tool': 'api',
|
||||
'workflow-tool': 'workflow',
|
||||
}
|
||||
const sectionByToolCategory: Record<ToolCategory, IntegrationSection> = {
|
||||
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<NavItem>(() => ({
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 bg-background-body">
|
||||
@ -342,26 +323,15 @@ export default function IntegrationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{section === 'provider' && (
|
||||
<div className="px-6 pt-6">
|
||||
<ModelProviderPage
|
||||
searchText={providerSearchText}
|
||||
onSearchTextChange={setProviderSearchText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{section === 'data-source' && (
|
||||
<div className="px-6 pt-6">
|
||||
<DataSourcePage />
|
||||
</div>
|
||||
)}
|
||||
{section === 'api-based-extension' && (
|
||||
<div className="px-6 pt-6">
|
||||
<ApiBasedExtensionPage />
|
||||
</div>
|
||||
)}
|
||||
{isToolSection && <ToolProviderList />}
|
||||
<div className={cn(
|
||||
'min-h-0 flex-1',
|
||||
useFillLayout ? 'flex flex-col overflow-hidden' : 'overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
<IntegrationSectionRenderer
|
||||
section={section}
|
||||
providerSearchText={providerSearchText}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
30
web/app/components/tools/plugin-category-page.tsx
Normal file
30
web/app/components/tools/plugin-category-page.tsx
Normal file
@ -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 (
|
||||
<PluginPageContextProvider key={category} initialFilters={initialFilters}>
|
||||
<div className="flex h-0 grow flex-col overflow-hidden bg-background-body">
|
||||
<PluginsPanel />
|
||||
</div>
|
||||
</PluginPageContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginCategoryPage
|
||||
@ -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<string>(TOOL_PROVIDER_CATEGORY_VALUES)
|
||||
const toolProviderCategorySet = new Set<string>(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<HTMLDivElement>(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)
|
||||
}}
|
||||
|
||||
@ -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(<Empty type={ToolTypeEnum.Custom} />)
|
||||
|
||||
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(<Empty type={ToolTypeEnum.MCP} />)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={() => router.push('/tools?category=workflow')}
|
||||
onClick={() => router.push(buildIntegrationPath('workflow-tool'))}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.manageInTools', { ns: 'workflow' })}
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "غير محدود",
|
||||
"loading": "جارٍ التحميل",
|
||||
"mainNav.help.docs": "الوثائق",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "فتح قائمة المساعدة",
|
||||
"mainNav.home": "الرئيسية",
|
||||
"mainNav.integrations": "التكاملات",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "الأعضاء",
|
||||
"settings.plugin": "الإضافات",
|
||||
"settings.provider": "مزود النموذج",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "مساحة العمل",
|
||||
"settings.workspaceSettings": "إعدادات مساحة العمل",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Unbegrenzt",
|
||||
"loading": "Wird geladen",
|
||||
"mainNav.help.docs": "Dokumentation",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Hilfemenü öffnen",
|
||||
"mainNav.home": "Startseite",
|
||||
"mainNav.integrations": "Integrationen",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Mitglieder",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Modellanbieter",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ARBEITSBEREICH",
|
||||
"settings.workspaceSettings": "Arbeitsbereich-Einstellungen",
|
||||
|
||||
@ -650,6 +650,7 @@
|
||||
"settings.members": "Members",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Model Provider",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"settings.workspaceSettings": "Workspace Settings",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Ilimitado",
|
||||
"loading": "Cargando",
|
||||
"mainNav.help.docs": "Documentación",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Abrir menú de ayuda",
|
||||
"mainNav.home": "Inicio",
|
||||
"mainNav.integrations": "Integraciones",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Miembros",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Proveedor de Modelo",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ESPACIO DE TRABAJO",
|
||||
"settings.workspaceSettings": "Configuración del espacio de trabajo",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "نامحدود",
|
||||
"loading": "در حال بارگذاری",
|
||||
"mainNav.help.docs": "مستندات",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "باز کردن منوی راهنما",
|
||||
"mainNav.home": "خانه",
|
||||
"mainNav.integrations": "یکپارچهسازیها",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "اعضا",
|
||||
"settings.plugin": "افزونهها",
|
||||
"settings.provider": "ارائه دهنده مدل",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "فضای کاری",
|
||||
"settings.workspaceSettings": "تنظیمات فضای کاری",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Illimité",
|
||||
"loading": "Chargement",
|
||||
"mainNav.help.docs": "Documentation",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Ouvrir le menu d’aide",
|
||||
"mainNav.home": "Accueil",
|
||||
"mainNav.integrations": "Intégrations",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Membres",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Fournisseur de Modèle",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ESPACE DE TRAVAIL",
|
||||
"settings.workspaceSettings": "Paramètres de l’espace de travail",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "असीमित",
|
||||
"loading": "लोड हो रहा है",
|
||||
"mainNav.help.docs": "दस्तावेज़",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "सहायता मेनू खोलें",
|
||||
"mainNav.home": "होम",
|
||||
"mainNav.integrations": "इंटीग्रेशन",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "सदस्य",
|
||||
"settings.plugin": "प्लगइन्स",
|
||||
"settings.provider": "मॉडल प्रदाता",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "कार्यस्थल",
|
||||
"settings.workspaceSettings": "कार्यस्थल सेटिंग्स",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Unlimited",
|
||||
"loading": "Memuat",
|
||||
"mainNav.help.docs": "Dokumentasi",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Buka menu bantuan",
|
||||
"mainNav.home": "Beranda",
|
||||
"mainNav.integrations": "Integrasi",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Anggota",
|
||||
"settings.plugin": "Plugin",
|
||||
"settings.provider": "Penyedia Model",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"settings.workspaceSettings": "Pengaturan Workspace",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Illimitato",
|
||||
"loading": "Caricamento",
|
||||
"mainNav.help.docs": "Documentazione",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Apri menu di aiuto",
|
||||
"mainNav.home": "Home",
|
||||
"mainNav.integrations": "Integrazioni",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Membri",
|
||||
"settings.plugin": "Plugin",
|
||||
"settings.provider": "Fornitore di Modelli",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"settings.workspaceSettings": "Impostazioni workspace",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "無制限",
|
||||
"loading": "読み込み中",
|
||||
"mainNav.help.docs": "ドキュメント",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "ヘルプメニューを開く",
|
||||
"mainNav.home": "ホーム",
|
||||
"mainNav.integrations": "連携",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "メンバー",
|
||||
"settings.plugin": "プラグイン",
|
||||
"settings.provider": "モデルプロバイダー",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ワークスペース",
|
||||
"settings.workspaceSettings": "ワークスペース設定",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "무제한",
|
||||
"loading": "로딩 중",
|
||||
"mainNav.help.docs": "문서",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "도움말 메뉴 열기",
|
||||
"mainNav.home": "홈",
|
||||
"mainNav.integrations": "연동",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "멤버",
|
||||
"settings.plugin": "플러그인",
|
||||
"settings.provider": "모델 제공자",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "작업 공간",
|
||||
"settings.workspaceSettings": "작업 공간 설정",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Unlimited",
|
||||
"loading": "Loading",
|
||||
"mainNav.help.docs": "Documentatie",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Helpmenu openen",
|
||||
"mainNav.home": "Start",
|
||||
"mainNav.integrations": "Integraties",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Members",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Model Provider",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"settings.workspaceSettings": "Workspace-instellingen",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Nieograniczony",
|
||||
"loading": "Ładowanie",
|
||||
"mainNav.help.docs": "Dokumentacja",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Otwórz menu pomocy",
|
||||
"mainNav.home": "Strona główna",
|
||||
"mainNav.integrations": "Integracje",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Członkowie",
|
||||
"settings.plugin": "Pluginy",
|
||||
"settings.provider": "Dostawca modelu",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "PRZESTRZEŃ ROBOCZA",
|
||||
"settings.workspaceSettings": "Ustawienia przestrzeni roboczej",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Ilimitado",
|
||||
"loading": "Carregando",
|
||||
"mainNav.help.docs": "Documentação",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Abrir menu de ajuda",
|
||||
"mainNav.home": "Início",
|
||||
"mainNav.integrations": "Integrações",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Membros",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Fornecedor de modelo",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ESPAÇO DE TRABALHO",
|
||||
"settings.workspaceSettings": "Configurações do espaço de trabalho",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Nelimitat",
|
||||
"loading": "Se încarcă",
|
||||
"mainNav.help.docs": "Documentație",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Deschide meniul de ajutor",
|
||||
"mainNav.home": "Acasă",
|
||||
"mainNav.integrations": "Integrări",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Membri",
|
||||
"settings.plugin": "Plugin-uri",
|
||||
"settings.provider": "Furnizor de modele",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "SPAȚIU DE LUCRU",
|
||||
"settings.workspaceSettings": "Setări spațiu de lucru",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Неограниченный",
|
||||
"loading": "Загрузка",
|
||||
"mainNav.help.docs": "Документация",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Открыть меню помощи",
|
||||
"mainNav.home": "Главная",
|
||||
"mainNav.integrations": "Интеграции",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Участники",
|
||||
"settings.plugin": "Плагины",
|
||||
"settings.provider": "Поставщик модели",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "РАБОЧЕЕ ПРОСТРАНСТВО",
|
||||
"settings.workspaceSettings": "Настройки рабочего пространства",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Brez omejitev",
|
||||
"loading": "Nalaganje",
|
||||
"mainNav.help.docs": "Dokumentacija",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Odpri meni pomoči",
|
||||
"mainNav.home": "Domov",
|
||||
"mainNav.integrations": "Integracije",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Člani",
|
||||
"settings.plugin": "Vtičniki",
|
||||
"settings.provider": "Ponudnik modelov",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "DELOVNI PROSTOR",
|
||||
"settings.workspaceSettings": "Nastavitve delovnega prostora",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "ไม่มีขีดจำกัด",
|
||||
"loading": "กำลังโหลด",
|
||||
"mainNav.help.docs": "เอกสาร",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "เปิดเมนูช่วยเหลือ",
|
||||
"mainNav.home": "หน้าแรก",
|
||||
"mainNav.integrations": "การผสานรวม",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "สมาชิก",
|
||||
"settings.plugin": "ปลั๊กอิน",
|
||||
"settings.provider": "ผู้ให้บริการโมเดล",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "พื้นที่",
|
||||
"settings.workspaceSettings": "การตั้งค่าพื้นที่ทำงาน",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Sınırsız",
|
||||
"loading": "Yükleniyor",
|
||||
"mainNav.help.docs": "Belgeler",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Yardım menüsünü aç",
|
||||
"mainNav.home": "Ana sayfa",
|
||||
"mainNav.integrations": "Entegrasyonlar",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Üyeler",
|
||||
"settings.plugin": "Eklentiler",
|
||||
"settings.provider": "Model Sağlayıcı",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "ÇALIŞMA ALANI",
|
||||
"settings.workspaceSettings": "Çalışma alanı ayarları",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Безмежний",
|
||||
"loading": "Завантаження",
|
||||
"mainNav.help.docs": "Документація",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Відкрити меню довідки",
|
||||
"mainNav.home": "Головна",
|
||||
"mainNav.integrations": "Інтеграції",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Учасники",
|
||||
"settings.plugin": "Плагіни",
|
||||
"settings.provider": "Постачальник моделі",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "РОБОЧИЙ ПРОСТІР",
|
||||
"settings.workspaceSettings": "Налаштування робочого простору",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "Vô hạn",
|
||||
"loading": "Đang tải",
|
||||
"mainNav.help.docs": "Tài liệu",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "Mở menu trợ giúp",
|
||||
"mainNav.home": "Trang chủ",
|
||||
"mainNav.integrations": "Tích hợp",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "Thành viên",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Nhà cung cấp mô hình",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "KHÔNG GIAN LÀM VIỆC",
|
||||
"settings.workspaceSettings": "Cài đặt không gian làm việc",
|
||||
|
||||
@ -650,6 +650,7 @@
|
||||
"settings.members": "成员",
|
||||
"settings.plugin": "插件",
|
||||
"settings.provider": "模型供应商",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "工作空间",
|
||||
"settings.workspaceSettings": "工作空间设置",
|
||||
|
||||
@ -221,6 +221,7 @@
|
||||
"license.unlimited": "無限制",
|
||||
"loading": "載入中",
|
||||
"mainNav.help.docs": "文件",
|
||||
"mainNav.help.learnDify": "Learn Dify",
|
||||
"mainNav.help.openMenu": "開啟幫助選單",
|
||||
"mainNav.home": "首頁",
|
||||
"mainNav.integrations": "整合",
|
||||
@ -649,6 +650,7 @@
|
||||
"settings.members": "成員",
|
||||
"settings.plugin": "外掛",
|
||||
"settings.provider": "模型供應商",
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workplaceGroup": "工作空間",
|
||||
"settings.workspaceSettings": "工作空間設定",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export {
|
||||
notFound,
|
||||
redirect,
|
||||
useParams,
|
||||
usePathname,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user