diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index deadf8e470e..2dede15330c 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -319,7 +319,7 @@ class PluginInstallationItemResponse(ResponseModel): plugin_unique_identifier: str version: str checksum: str - declaration: Mapping[str, Any] + declaration: PluginDeclarationResponse class PluginInstallationsResponse(ResponseModel): diff --git a/api/core/plugin/entities/marketplace.py b/api/core/plugin/entities/marketplace.py index 03398873e30..0911adf2fd3 100644 --- a/api/core/plugin/entities/marketplace.py +++ b/api/core/plugin/entities/marketplace.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field, computed_field, model_validator @@ -32,7 +32,9 @@ class MarketplacePluginDeclaration(BaseModel): latest_package_identifier: str = Field( ..., description="Unique identifier for the latest package release of the plugin" ) - status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`") + status: Literal["active", "deleted"] = Field( + ..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`" + ) deprecated_reason: str = Field( ..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)" ) diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 4b749bb4c90..b39032894e4 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -16,7 +16,7 @@ import logging import time from collections.abc import Mapping, Sequence from mimetypes import guess_type -from typing import Any, ClassVar +from typing import Any, ClassVar, Literal from pydantic import BaseModel, TypeAdapter, ValidationError from redis import RedisError @@ -75,7 +75,7 @@ class PluginService: plugin_id: str version: str unique_identifier: str - status: str + status: Literal["active", "deleted"] deprecated_reason: str alternative_plugin_id: str diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 3bd1bbed94f..3e9b93b086e 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18093,7 +18093,7 @@ Enum class for large language model mode. | alternative_plugin_id | string | | Yes | | deprecated_reason | string | | Yes | | plugin_id | string | | Yes | -| status | string | | Yes | +| status | string,
**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes | | unique_identifier | string | | Yes | | version | string | | Yes | @@ -19590,7 +19590,7 @@ Shared permission levels for resources (datasets, credentials, etc.) | ---- | ---- | ----------- | -------- | | checksum | string | | Yes | | created_at | dateTime | | Yes | -| declaration | object | | Yes | +| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes | | endpoints_active | integer | | Yes | | endpoints_setups | integer | | Yes | | id | string | | Yes | diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index bc76560dcac..2bd6be3bc7a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -42,6 +42,7 @@ from controllers.console.workspace.plugin import ( PluginUploadFromGithubApi, PluginUploadFromPkgApi, ) +from core.plugin.entities.plugin import PluginInstallation from core.plugin.impl.exc import PluginDaemonClientSideError from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission @@ -478,12 +479,23 @@ class TestPluginListInstallationsFromIdsApi: app.test_request_context("/", json=payload), patch( "controllers.console.workspace.plugin.PluginService.list_installations_from_ids", - return_value=[{"id": "p1"}], + return_value=[PluginInstallation.model_validate(_plugin_category_list_item())], ), ): result = method(api, "t1") - assert "plugins" in result + assert result["plugins"][0]["id"] == "entity-1" + assert result["plugins"][0]["plugin_id"] == "test-author/test-plugin" + assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum" + assert result["plugins"][0]["version"] == "1.0.0" + assert result["plugins"][0]["declaration"]["name"] == "test-plugin" + assert "name" not in result["plugins"][0] + assert "installation_id" not in result["plugins"][0] + assert "latest_version" not in result["plugins"][0] + assert "latest_unique_identifier" not in result["plugins"][0] + assert "status" not in result["plugins"][0] + assert "deprecated_reason" not in result["plugins"][0] + assert "alternative_plugin_id" not in result["plugins"][0] def test_daemon_error(self, app: Flask): api = PluginListInstallationsFromIdsApi() diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index d4d7394ec94..5fd7557b763 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -1134,9 +1134,7 @@ export type PluginAutoUpgradeSettingsResponseModel = { export type PluginInstallationItemResponse = { checksum: string created_at: string - declaration: { - [key: string]: unknown - } + declaration: PluginDeclarationResponse endpoints_active: number endpoints_setups: number id: string @@ -1156,7 +1154,7 @@ export type LatestPluginCache = { alternative_plugin_id: string deprecated_reason: string plugin_id: string - status: string + status: 'active' | 'deleted' unique_identifier: string version: string } @@ -1500,39 +1498,6 @@ export type StrategySetting = 'disabled' | 'fix_only' | 'latest' export type UpgradeMode = 'all' | 'exclude' | 'partial' -export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' - -export type CoreToolsEntitiesCommonEntitiesI18nObject = { - en_US: string - ja_JP?: string | null - pt_BR?: string | null - zh_Hans?: string | null -} - -export type PluginCategoryBuiltinToolResponse = { - author: string - description: CoreToolsEntitiesCommonEntitiesI18nObject - label: CoreToolsEntitiesCommonEntitiesI18nObject - labels: Array - name: string - output_schema: { - [key: string]: unknown - } - parameters?: Array<{ - [key: string]: unknown - }> | null - [key: string]: unknown -} - -export type ToolProviderType - = | 'api' - | 'app' - | 'builtin' - | 'dataset-retrieval' - | 'mcp' - | 'plugin' - | 'workflow' - export type PluginDeclarationResponse = { agent_strategy?: { [key: string]: unknown @@ -1573,6 +1538,39 @@ export type PluginDeclarationResponse = { version: string } +export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' + +export type CoreToolsEntitiesCommonEntitiesI18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type PluginCategoryBuiltinToolResponse = { + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + label: CoreToolsEntitiesCommonEntitiesI18nObject + labels: Array + name: string + output_schema: { + [key: string]: unknown + } + parameters?: Array<{ + [key: string]: unknown + }> | null + [key: string]: unknown +} + +export type ToolProviderType + = | 'api' + | 'app' + | 'builtin' + | 'dataset-retrieval' + | 'mcp' + | 'plugin' + | 'workflow' + export type RbacRoleAccount = { account_id: string account_name?: string diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 6d0a1f0c404..e4f28cfacaa 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -1101,7 +1101,7 @@ export const zLatestPluginCache = z.object({ alternative_plugin_id: z.string(), deprecated_reason: z.string(), plugin_id: z.string(), - status: z.string(), + status: z.enum(['active', 'deleted']), unique_identifier: z.string(), version: z.string(), }) @@ -1716,33 +1716,6 @@ export const zPluginAutoUpgradeFetchResponse = z.object({ */ export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote']) -/** - * PluginInstallationItemResponse - */ -export const zPluginInstallationItemResponse = z.object({ - checksum: z.string(), - created_at: z.iso.datetime(), - declaration: z.record(z.string(), z.unknown()), - endpoints_active: z.int(), - endpoints_setups: z.int(), - id: z.string(), - meta: z.record(z.string(), z.unknown()), - plugin_id: z.string(), - plugin_unique_identifier: z.string(), - runtime_type: z.string(), - source: zPluginInstallationSource, - tenant_id: z.string(), - updated_at: z.iso.datetime(), - version: z.string(), -}) - -/** - * PluginInstallationsResponse - */ -export const zPluginInstallationsResponse = z.object({ - plugins: z.array(zPluginInstallationItemResponse), -}) - /** * I18nObject * @@ -2348,6 +2321,33 @@ export const zPluginDeclarationResponse = z.object({ version: z.string(), }) +/** + * PluginInstallationItemResponse + */ +export const zPluginInstallationItemResponse = z.object({ + checksum: z.string(), + created_at: z.iso.datetime(), + declaration: zPluginDeclarationResponse, + endpoints_active: z.int(), + endpoints_setups: z.int(), + id: z.string(), + meta: z.record(z.string(), z.unknown()), + plugin_id: z.string(), + plugin_unique_identifier: z.string(), + runtime_type: z.string(), + source: zPluginInstallationSource, + tenant_id: z.string(), + updated_at: z.iso.datetime(), + version: z.string(), +}) + +/** + * PluginInstallationsResponse + */ +export const zPluginInstallationsResponse = z.object({ + plugins: z.array(zPluginInstallationItemResponse), +}) + /** * PluginCategoryInstalledPluginResponse */ diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx index c71f79b042f..f7bf0d1ce05 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx @@ -84,6 +84,9 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ })) vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: () => ({ + data: { plugins: [] }, + }), usePluginAutoUpgradeSettings: () => ({ data: { category: 'model', @@ -111,25 +114,40 @@ vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() - const originalPlugins = actual.consoleQuery.plugins as unknown as Record + const originalWorkspaces = actual.consoleQuery.workspaces return { ...actual, consoleQuery: new Proxy(actual.consoleQuery, { get(target, prop) { - if (prop === 'plugins') { + if (prop === 'workspaces') { return { - ...originalPlugins, - checkInstalled: { - queryOptions: () => ({ - queryKey: ['plugins', 'checkInstalled'], - queryFn: () => new Promise(() => {}), - }), - }, - latestVersions: { - queryOptions: () => ({ - queryKey: ['plugins', 'latestVersions'], - queryFn: () => new Promise(() => {}), - }), + ...originalWorkspaces, + current: { + ...originalWorkspaces.current, + plugin: { + ...originalWorkspaces.current.plugin, + list: { + ...originalWorkspaces.current.plugin.list, + installations: { + ids: { + post: { + queryOptions: () => ({ + queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'], + queryFn: () => new Promise(() => {}), + }), + }, + }, + }, + latestVersions: { + post: { + queryOptions: () => ({ + queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'], + queryFn: () => new Promise(() => {}), + }), + }, + }, + }, + }, }, } } diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index a6500b5e064..426276cfc5f 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -163,6 +163,9 @@ vi.mock('@/service/use-plugins', () => ({ useInstalledPluginList: () => ({ data: { plugins: [] }, }), + useCheckInstalled: () => ({ + data: { plugins: [] }, + }), usePluginAutoUpgradeSettings: () => ({ data: mockReferenceSetting.auto_upgrade ? { @@ -230,25 +233,40 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() - const originalPlugins = actual.consoleQuery.plugins as unknown as Record + const originalWorkspaces = actual.consoleQuery.workspaces return { ...actual, consoleQuery: new Proxy(actual.consoleQuery, { get(target, prop) { - if (prop === 'plugins') { + if (prop === 'workspaces') { return { - ...originalPlugins, - checkInstalled: { - queryOptions: () => ({ - queryKey: ['plugins', 'checkInstalled'], - queryFn: () => new Promise(() => {}), - }), - }, - latestVersions: { - queryOptions: () => ({ - queryKey: ['plugins', 'latestVersions'], - queryFn: () => new Promise(() => {}), - }), + ...originalWorkspaces, + current: { + ...originalWorkspaces.current, + plugin: { + ...originalWorkspaces.current.plugin, + list: { + ...originalWorkspaces.current.plugin.list, + installations: { + ids: { + post: { + queryOptions: () => ({ + queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'], + queryFn: () => new Promise(() => {}), + }), + }, + }, + }, + latestVersions: { + post: { + queryOptions: () => ({ + queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'], + queryFn: () => new Promise(() => {}), + }), + }, + }, + }, + }, }, } } diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 24276bbb3db..5d8ddfd8f13 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -3,7 +3,7 @@ import type { ModelProvider, } from './declarations' import type { PluginDetail } from '@/app/components/plugins/types' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { noop } from 'es-toolkit/function' import { useMemo } from 'react' @@ -14,7 +14,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { consoleQuery } from '@/service/client' +import { useCheckInstalled } from '@/service/use-plugins' import UpdateSettingDialog from '../update-setting-dialog' import { CustomConfigurationStatusEnum, @@ -64,11 +64,10 @@ const ModelProviderPage = ({ const allPluginIds = useMemo(() => { return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))] }, [providers]) - const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({ - input: { body: { plugin_ids: allPluginIds } }, + const { data: installedPlugins } = useCheckInstalled({ + pluginIds: allPluginIds, enabled: allPluginIds.length > 0, - staleTime: 0, - })) + }) const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins) const pluginDetailMap = useMemo(() => { const map = new Map() diff --git a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts index 5e76afa218a..741fea0f07d 100644 --- a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts +++ b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts @@ -11,9 +11,17 @@ vi.mock('@tanstack/react-query', () => ({ vi.mock('@/service/client', () => ({ consoleQuery: { - plugins: { - latestVersions: { - queryOptions: vi.fn((options: unknown) => options), + workspaces: { + current: { + plugin: { + list: { + latestVersions: { + post: { + queryOptions: vi.fn((options: unknown) => options), + }, + }, + }, + }, }, }, }, @@ -55,7 +63,7 @@ describe('usePluginsWithLatestVersion', () => { const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({ + expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({ input: { body: { plugin_ids: [] } }, enabled: false, }) @@ -109,7 +117,7 @@ describe('usePluginsWithLatestVersion', () => { const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({ + expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({ input: { body: { plugin_ids: ['plugin-1'] } }, enabled: true, }) diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts index 5349cd4e852..28c5f17e809 100644 --- a/web/app/components/plugins/hooks.ts +++ b/web/app/components/plugins/hooks.ts @@ -109,7 +109,7 @@ export function usePluginsWithLatestVersion(plugins: PluginDetail[] = EMPTY_PLUG [plugins], ) - const { data: latestVersionData } = useQuery(consoleQuery.plugins.latestVersions.queryOptions({ + const { data: latestVersionData } = useQuery(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({ input: { body: { plugin_ids: marketplacePluginIds } }, enabled: !!marketplacePluginIds.length, })) diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 0d4863c5629..0e744ea3f46 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -450,18 +450,6 @@ export type InstalledPluginCategoryListResponse = { has_more: boolean } -export type InstalledLatestVersionResponse = { - versions: { - [plugin_id: string]: { - unique_identifier: string - version: string - status: 'active' | 'deleted' - deprecated_reason: string - alternative_plugin_id: string - } | null - } -} - export type UninstallPluginResponse = { success: boolean } diff --git a/web/contract/console/explore.ts b/web/contract/console/explore.ts index 11c75397b9a..a1d8c980c99 100644 --- a/web/contract/console/explore.ts +++ b/web/contract/console/explore.ts @@ -158,3 +158,7 @@ export const exploreRouterContract = { installedAppMeta: exploreInstalledAppMetaContract, banners: exploreBannersContract, } + +export const exploreConsoleRouterContract = { + explore: exploreRouterContract, +} diff --git a/web/contract/console/plugins.ts b/web/contract/console/plugins.ts index 4269fd2acad..500f368bc34 100644 --- a/web/contract/console/plugins.ts +++ b/web/contract/console/plugins.ts @@ -1,32 +1 @@ -import type { InstalledLatestVersionResponse, PluginDetail } from '@/app/components/plugins/types' -import { type } from '@orpc/contract' -import { base } from '../base' - -export const pluginCheckInstalledContract = base - .route({ - path: '/workspaces/current/plugin/list/installations/ids', - method: 'POST', - }) - .input(type<{ - body: { - plugin_ids: string[] - } - }>()) - .output(type<{ plugins: PluginDetail[] }>()) - -export const pluginLatestVersionsContract = base - .route({ - path: '/workspaces/current/plugin/list/latest-versions', - method: 'POST', - }) - .input(type<{ - body: { - plugin_ids: string[] - } - }>()) - .output(type()) - -export const pluginsRouterContract = { - checkInstalled: pluginCheckInstalledContract, - latestVersions: pluginLatestVersionsContract, -} +export const pluginsConsoleRouterContract = {} diff --git a/web/contract/console/snippets.ts b/web/contract/console/snippets.ts index 0c9a9f7d9ec..768510dd240 100644 --- a/web/contract/console/snippets.ts +++ b/web/contract/console/snippets.ts @@ -370,3 +370,7 @@ export const snippetsRouterContract = { runDraftWorkflow: runSnippetDraftWorkflowContract, stopWorkflowTask: stopSnippetWorkflowTaskContract, } + +export const snippetsConsoleRouterContract = { + snippets: snippetsRouterContract, +} diff --git a/web/contract/console/try-app.ts b/web/contract/console/try-app.ts index eab05e7f135..76e01ca12c3 100644 --- a/web/contract/console/try-app.ts +++ b/web/contract/console/try-app.ts @@ -63,3 +63,7 @@ export const trialAppsRouterContract = { parameters: trialAppParametersContract, workflows: trialAppWorkflowsContract, } + +export const trialAppsConsoleRouterContract = { + trialApps: trialAppsRouterContract, +} diff --git a/web/contract/router.ts b/web/contract/router.ts index 45aea251f03..48ff9a2579b 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -45,10 +45,10 @@ import { workflowGenerate } from '@dify/contracts/api/console/workflow-generate/ import { workflow } from '@dify/contracts/api/console/workflow/orpc.gen' import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen' import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen' -import { exploreRouterContract } from './console/explore' -import { pluginsRouterContract } from './console/plugins' -import { snippetsRouterContract } from './console/snippets' -import { trialAppsRouterContract } from './console/try-app' +import { exploreConsoleRouterContract } from './console/explore' +import { pluginsConsoleRouterContract } from './console/plugins' +import { snippetsConsoleRouterContract } from './console/snippets' +import { trialAppsConsoleRouterContract } from './console/try-app' const communityContract = { account, @@ -102,8 +102,8 @@ const communityContract = { export const consoleRouterContract = { enterprise: enterpriseContract, ...communityContract, - explore: exploreRouterContract, - plugins: pluginsRouterContract, - snippets: snippetsRouterContract, - trialApps: trialAppsRouterContract, + ...exploreConsoleRouterContract, + ...pluginsConsoleRouterContract, + ...snippetsConsoleRouterContract, + ...trialAppsConsoleRouterContract, } diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index 015614c4a50..6d8374591a6 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -1,11 +1,14 @@ +import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import type { Permissions, PluginTaskStart } from '@/app/components/plugins/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/base/form/types' import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { PermissionType, PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { + normalizeInstalledPluginDetail, useInstalledPluginList, useMutationPluginAutoUpgradeSettings, useMutationPluginPermissionSettings, @@ -67,6 +70,177 @@ const createWrapper = (queryClient: QueryClient) => { } } +const createPluginInstallation = (): PluginInstallationItemResponse => ({ + id: 'installation-row-id', + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-02T00:00:00Z', + plugin_id: 'langgenius/full-declaration', + plugin_unique_identifier: 'langgenius/full-declaration:1.0.0@test', + tenant_id: 'tenant-id', + endpoints_setups: 1, + endpoints_active: 1, + version: '1.0.0', + source: PluginSource.marketplace, + runtime_type: 'local', + checksum: 'checksum', + meta: { + repo: 'langgenius/full-declaration', + version: '1.0.0', + package: 'full-declaration.difypkg', + }, + declaration: { + version: '1.0.0', + author: 'Dify', + icon: 'icon.svg', + icon_dark: 'icon-dark.svg', + name: 'full-declaration', + category: PluginCategoryEnum.trigger, + label: { en_US: 'Full declaration', zh_Hans: 'Full declaration' }, + description: { en_US: 'Full declaration plugin', zh_Hans: 'Full declaration plugin' }, + created_at: '2026-06-01T00:00:00Z', + resource: {}, + plugins: {}, + verified: true, + tags: ['automation'], + meta: { + version: '1.0.0', + minimum_dify_version: '1.4.0', + }, + tool: { + identity: { + author: 'Dify', + name: 'tool-provider', + description: { en_US: 'Tool provider' }, + icon: 'tool.svg', + label: { en_US: 'Tool provider' }, + tags: ['tool'], + }, + credentials_schema: [ + { + name: 'api_key', + label: { en_US: 'API key' }, + type: 'secret-input', + required: true, + default: 'token', + }, + ], + }, + datasource: { + identity: { + author: 'Dify', + name: 'datasource-provider', + description: { en_US: 'Datasource provider' }, + icon: 'datasource.svg', + label: { en_US: 'Datasource provider' }, + tags: ['datasource'], + }, + credentials_schema: [], + }, + endpoint: { + settings: [ + { + name: 'endpoint_secret', + label: { en_US: 'Endpoint secret' }, + type: 'secret-input', + required: true, + default: '', + }, + ], + endpoints: [ + { + path: '/webhook', + method: 'POST', + hidden: false, + }, + ], + }, + trigger: { + events: [ + { + name: 'issue_created', + identity: { + author: 'Dify', + name: 'issue_created', + label: { en_US: 'Issue created' }, + provider: 'github', + }, + description: { en_US: 'Issue created event' }, + parameters: [ + { + name: 'retry_count', + label: { en_US: 'Retry count' }, + type: 'number', + default: 0, + required: false, + multiple: false, + }, + ], + output_schema: { + type: 'object', + }, + }, + ], + identity: { + author: 'Dify', + name: 'github-trigger', + label: { en_US: 'GitHub trigger' }, + description: { en_US: 'GitHub trigger provider' }, + icon: 'trigger.svg', + tags: ['trigger'], + }, + subscription_constructor: { + credentials_schema: [], + oauth_schema: { + client_schema: [], + credentials_schema: [], + }, + parameters: [ + { + name: 'max_retries', + label: { en_US: 'Max retries' }, + type: 'number', + default: 3, + required: false, + multiple: false, + }, + ], + }, + subscription_schema: [ + { + name: 'repository', + label: { en_US: 'Repository' }, + type: 'text-input', + required: true, + }, + ], + }, + }, +}) + +describe('normalizeInstalledPluginDetail', () => { + it('should preserve generated plugin declaration capabilities', () => { + const detail = normalizeInstalledPluginDetail(createPluginInstallation()) + + expect(detail.name).toBe('full-declaration') + expect(detail.installation_id).toBe('installation-row-id') + expect(detail.latest_version).toBe('1.0.0') + expect(detail.latest_unique_identifier).toBe('langgenius/full-declaration:1.0.0@test') + expect(detail.status).toBe('active') + expect(detail.declaration.tool?.identity.name).toBe('tool-provider') + expect(detail.declaration.tool?.credentials_schema[0]?.name).toBe('api_key') + expect(detail.declaration.datasource?.identity.name).toBe('datasource-provider') + expect(detail.declaration.endpoint?.endpoints?.[0]).toEqual({ + path: '/webhook', + method: 'POST', + hidden: false, + }) + expect(detail.declaration.trigger.identity.name).toBe('github-trigger') + expect(detail.declaration.trigger.events[0]?.parameters[0]?.default).toBe(0) + expect(detail.declaration.trigger.subscription_constructor.parameters[0]?.type).toBe(FormTypeEnum.textNumber) + expect(detail.declaration.trigger.subscription_schema[0]?.name).toBe('repository') + }) +}) + describe('use-plugins mutations', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/service/console-router-loader.ts b/web/service/console-router-loader.ts index ec01453dfa8..60ce244620c 100644 --- a/web/service/console-router-loader.ts +++ b/web/service/console-router-loader.ts @@ -1,7 +1,9 @@ import type { AnyContractRouter } from '@orpc/contract' import { contractLoaders } from '@dify/contracts/api/console/orpc.gen' -const wrapConsoleContract = (segment: string, contract: unknown) => ({ [segment]: contract }) as AnyContractRouter +type ConsoleContractExtension = Record + +const wrapConsoleContract = (segment: string, contract: unknown): ConsoleContractExtension => ({ [segment]: contract }) async function loadGeneratedConsoleContract(segment: string) { const loader = contractLoaders[segment as keyof typeof contractLoaders] @@ -11,18 +13,21 @@ async function loadGeneratedConsoleContract(segment: string) { return loader() as Promise } -const customConsoleContractLoaders: Record Promise> = { +const customConsoleContractLoaders: Record Promise> = { enterprise: () => import('@dify/contracts/enterprise/orpc.gen').then(({ contract }) => wrapConsoleContract('enterprise', contract)), - explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)), - plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)), - snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)), - trialApps: () => import('@/contract/console/try-app').then(({ trialAppsRouterContract }) => wrapConsoleContract('trialApps', trialAppsRouterContract)), + explore: () => import('@/contract/console/explore').then(({ exploreConsoleRouterContract }) => exploreConsoleRouterContract), + plugins: () => import('@/contract/console/plugins').then(({ pluginsConsoleRouterContract }) => pluginsConsoleRouterContract), + snippets: () => import('@/contract/console/snippets').then(({ snippetsConsoleRouterContract }) => snippetsConsoleRouterContract), + trialApps: () => import('@/contract/console/try-app').then(({ trialAppsConsoleRouterContract }) => trialAppsConsoleRouterContract), } export async function loadConsoleContractForSegment(segment: string) { const customContractLoader = customConsoleContractLoaders[segment] - if (customContractLoader) - return customContractLoader() + if (customContractLoader) { + const customContract = await customContractLoader() + if (Object.keys(customContract).length > 0) + return customContract as AnyContractRouter + } const generatedContract = await loadGeneratedConsoleContract(segment) if (generatedContract) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 38b36c89c11..b13fd0f4241 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -1,3 +1,4 @@ +import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query' import type { FormOption, @@ -14,10 +15,12 @@ import type { InstalledPluginListWithTotalResponse, InstallPackageResponse, InstallStatusResponse, + MetaData, PackageDependency, Permissions, Plugin, PluginDeclaration, + PluginDetail, PluginInfoFromMarketPlace, PluginsFromMarketplaceByInfoResponse, PluginsFromMarketplaceResponse, @@ -36,9 +39,10 @@ import { } from '@tanstack/react-query' import { cloneDeep } from 'es-toolkit/object' import { useCallback, useEffect, useRef } from 'react' +import { FormTypeEnum } from '@/app/components/base/form/types' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' -import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { useAppContext } from '@/context/app-context' import { fetchModelProviderModelList } from '@/service/common' import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins' @@ -56,6 +60,417 @@ type PluginTaskListResponse = { tasks: PluginTask[] } +const getString = (value: unknown) => { + return typeof value === 'string' ? value : '' +} + +const getBoolean = (value: unknown) => { + return value === true +} + +const getNumber = (value: unknown) => { + return typeof value === 'number' ? value : undefined +} + +const isRecord = (value: unknown): value is Record => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const getRecord = (value: unknown, key: string) => { + if (!isRecord(value)) + return undefined + + const child = value[key] + return isRecord(child) ? child : undefined +} + +const getRecordArray = (value: unknown, key: string) => { + if (!isRecord(value)) + return [] + + const child = value[key] + return Array.isArray(child) ? child.filter(isRecord) : [] +} + +const getStringArray = (value: unknown) => { + return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [] +} + +const getI18nValue = (value: object | null | undefined, key: string) => { + return value ? getString(Object.entries(value).find(([itemKey]) => itemKey === key)?.[1]) : '' +} + +const normalizeI18nObject = (value: object | null | undefined, fallback = ''): PluginDeclaration['label'] => { + const en = getI18nValue(value, 'en_US') || getI18nValue(value, 'en-US') || fallback + const zhHans = getI18nValue(value, 'zh_Hans') || getI18nValue(value, 'zh-Hans') || en + const ja = getI18nValue(value, 'ja_JP') || getI18nValue(value, 'ja-JP') || en + const ptBr = getI18nValue(value, 'pt_BR') || getI18nValue(value, 'pt-BR') || en + + return { + 'en-US': en, + 'zh-Hans': zhHans, + 'zh-Hant': en, + 'pt-BR': ptBr, + 'es-ES': en, + 'fr-FR': en, + 'de-DE': en, + 'ja-JP': ja, + 'ko-KR': en, + 'ru-RU': en, + 'it-IT': en, + 'th-TH': en, + 'uk-UA': en, + 'vi-VN': en, + 'ro-RO': en, + 'pl-PL': en, + 'hi-IN': en, + 'tr-TR': en, + 'fa-IR': en, + 'sl-SI': en, + 'id-ID': en, + 'nl-NL': en, + 'ar-TN': en, + 'en_US': en, + 'zh_Hans': zhHans, + 'ja_JP': ja, + } +} + +const normalizePluginCategory = (category: PluginInstallationItemResponse['declaration']['category']): PluginCategoryEnum => { + switch (category) { + case PluginCategoryEnum.tool: + return PluginCategoryEnum.tool + case PluginCategoryEnum.model: + return PluginCategoryEnum.model + case PluginCategoryEnum.datasource: + return PluginCategoryEnum.datasource + case PluginCategoryEnum.trigger: + return PluginCategoryEnum.trigger + case PluginCategoryEnum.agent: + return PluginCategoryEnum.agent + case PluginCategoryEnum.extension: + return PluginCategoryEnum.extension + } + return PluginCategoryEnum.extension +} + +const normalizePluginSource = (source: PluginInstallationItemResponse['source']): PluginSource => { + switch (source) { + case PluginSource.github: + return PluginSource.github + case PluginSource.marketplace: + return PluginSource.marketplace + case PluginSource.local: + return PluginSource.local + case PluginSource.debugging: + return PluginSource.debugging + } + return PluginSource.marketplace +} + +const normalizePluginMeta = (meta: Record): MetaData => { + return { + repo: getString(meta.repo), + version: getString(meta.version), + package: getString(meta.package), + } +} + +const normalizeToolCredentialOption = ( + option: Record, +): NonNullable['credentials_schema'][number]['options']>[number] => ({ + label: normalizeI18nObject(getRecord(option, 'label'), getString(option.value)), + value: getString(option.value), +}) + +const normalizeToolCredential = ( + credential: Record, +): NonNullable['credentials_schema'][number] => ({ + name: getString(credential.name), + label: normalizeI18nObject(getRecord(credential, 'label'), getString(credential.name)), + help: credential.help === null ? null : normalizeI18nObject(getRecord(credential, 'help')), + placeholder: normalizeI18nObject(getRecord(credential, 'placeholder')), + type: getString(credential.type), + required: getBoolean(credential.required), + default: getString(credential.default), + options: getRecordArray(credential, 'options').map(normalizeToolCredentialOption), +}) + +const normalizePluginToolDeclaration = (value: unknown): PluginDeclaration['tool'] => { + if (!isRecord(value)) + return undefined + + const identity = getRecord(value, 'identity') + if (!identity) + return undefined + + const name = getString(identity.name) + + return { + identity: { + author: getString(identity.author), + name, + description: normalizeI18nObject(getRecord(identity, 'description')), + icon: getString(identity.icon), + label: normalizeI18nObject(getRecord(identity, 'label'), name), + tags: getStringArray(identity.tags), + }, + credentials_schema: getRecordArray(value, 'credentials_schema').map(normalizeToolCredential), + } +} + +const normalizePluginEndpointDeclaration = (value: unknown): PluginDeclaration['endpoint'] => { + if (!isRecord(value)) + return undefined + + return { + settings: getRecordArray(value, 'settings').map(normalizeToolCredential), + endpoints: getRecordArray(value, 'endpoints').map(endpoint => ({ + path: getString(endpoint.path), + method: getString(endpoint.method), + hidden: endpoint.hidden === undefined ? undefined : getBoolean(endpoint.hidden), + })), + } +} + +const normalizeParameterDefault = (value: unknown) => { + if (Array.isArray(value)) + return value.filter(item => typeof item === 'string') + if (typeof value === 'string') + return value + if (value === undefined || value === null) + return undefined + return String(value) +} + +const normalizeFormType = (type: unknown): FormTypeEnum => { + switch (type) { + case FormTypeEnum.appSelector: + return FormTypeEnum.appSelector + case FormTypeEnum.boolean: + return FormTypeEnum.boolean + case FormTypeEnum.checkbox: + return FormTypeEnum.checkbox + case FormTypeEnum.dynamicSelect: + return FormTypeEnum.dynamicSelect + case FormTypeEnum.file: + return FormTypeEnum.file + case FormTypeEnum.files: + return FormTypeEnum.files + case FormTypeEnum.modelSelector: + return FormTypeEnum.modelSelector + case FormTypeEnum.multiToolSelector: + return FormTypeEnum.multiToolSelector + case FormTypeEnum.radio: + return FormTypeEnum.radio + case FormTypeEnum.secretInput: + return FormTypeEnum.secretInput + case FormTypeEnum.select: + return FormTypeEnum.select + case FormTypeEnum.textNumber: + case 'number': + return FormTypeEnum.textNumber + case FormTypeEnum.textInput: + case 'string': + case 'array': + case 'object': + return FormTypeEnum.textInput + } + return FormTypeEnum.textInput +} + +const normalizeParameterOption = ( + option: Record, +): NonNullable[number] => ({ + value: getString(option.value), + label: normalizeI18nObject(getRecord(option, 'label'), getString(option.value)), + icon: getString(option.icon) || undefined, +}) + +const normalizeParameterSchema = ( + parameter: Record, +): PluginDeclaration['trigger']['subscription_schema'][number] => ({ + name: getString(parameter.name), + label: normalizeI18nObject(getRecord(parameter, 'label'), getString(parameter.name)), + type: normalizeFormType(parameter.type), + auto_generate: parameter.auto_generate ?? undefined, + template: parameter.template ?? undefined, + scope: parameter.scope ?? undefined, + required: getBoolean(parameter.required), + multiple: getBoolean(parameter.multiple), + default: normalizeParameterDefault(parameter.default), + min: parameter.min ?? undefined, + max: parameter.max ?? undefined, + precision: parameter.precision ?? undefined, + options: getRecordArray(parameter, 'options').map(normalizeParameterOption), + description: normalizeI18nObject(getRecord(parameter, 'description')), +}) + +const normalizeCredentialSchema = ( + schema: Record, +): PluginDeclaration['trigger']['subscription_constructor']['credentials_schema'][number] => ({ + name: getString(schema.name), + label: normalizeI18nObject(getRecord(schema, 'label'), getString(schema.name)), + description: normalizeI18nObject(getRecord(schema, 'description')), + type: normalizeFormType(schema.type), + scope: schema.scope ?? undefined, + required: getBoolean(schema.required), + default: schema.default ?? undefined, + options: getRecordArray(schema, 'options').map(normalizeParameterOption), + help: normalizeI18nObject(getRecord(schema, 'help')), + url: getString(schema.url), + placeholder: normalizeI18nObject(getRecord(schema, 'placeholder')), +}) + +const normalizeTriggerEventParameter = ( + parameter: Record, +): PluginDeclaration['trigger']['events'][number]['parameters'][number] => ({ + name: getString(parameter.name), + label: normalizeI18nObject(getRecord(parameter, 'label'), getString(parameter.name)), + type: getString(parameter.type), + auto_generate: parameter.auto_generate ?? undefined, + template: parameter.template ?? undefined, + scope: parameter.scope ?? undefined, + required: getBoolean(parameter.required), + multiple: getBoolean(parameter.multiple), + default: parameter.default ?? '', + min: getNumber(parameter.min), + max: getNumber(parameter.max), + precision: getNumber(parameter.precision), + options: getRecordArray(parameter, 'options').map(normalizeParameterOption), + description: normalizeI18nObject(getRecord(parameter, 'description')), +}) + +const normalizeTriggerEvent = ( + event: Record, +): PluginDeclaration['trigger']['events'][number] => { + const identity = getRecord(event, 'identity') + const name = getString(event.name) + + return { + name, + identity: { + author: getString(identity?.author), + name: getString(identity?.name) || name, + label: normalizeI18nObject(getRecord(identity, 'label'), name), + provider: getString(identity?.provider) || undefined, + }, + description: normalizeI18nObject(getRecord(event, 'description'), name), + parameters: getRecordArray(event, 'parameters').map(normalizeTriggerEventParameter), + output_schema: getRecord(event, 'output_schema') ?? {}, + } +} + +const createEmptyTrigger = (name: string): PluginDeclaration['trigger'] => ({ + events: [], + identity: { + author: '', + name, + label: normalizeI18nObject(undefined, name), + description: normalizeI18nObject(undefined), + icon: '', + tags: [], + }, + subscription_constructor: { + credentials_schema: [], + oauth_schema: { + client_schema: [], + credentials_schema: [], + }, + parameters: [], + }, + subscription_schema: [], +}) + +const normalizePluginTriggerDeclaration = ( + value: unknown, + fallbackName: string, +): PluginDeclaration['trigger'] => { + if (!isRecord(value)) + return createEmptyTrigger(fallbackName) + + const identity = getRecord(value, 'identity') + const subscriptionConstructor = getRecord(value, 'subscription_constructor') + const oauthSchema = getRecord(subscriptionConstructor, 'oauth_schema') + + return { + events: getRecordArray(value, 'events').map(normalizeTriggerEvent), + identity: { + author: getString(identity?.author), + name: getString(identity?.name) || fallbackName, + label: normalizeI18nObject(getRecord(identity, 'label'), fallbackName), + description: normalizeI18nObject(getRecord(identity, 'description')), + icon: getString(identity?.icon), + tags: getStringArray(identity?.tags), + }, + subscription_constructor: { + credentials_schema: getRecordArray(subscriptionConstructor, 'credentials_schema').map(normalizeCredentialSchema), + oauth_schema: { + client_schema: getRecordArray(oauthSchema, 'client_schema').map(normalizeCredentialSchema), + credentials_schema: getRecordArray(oauthSchema, 'credentials_schema').map(normalizeCredentialSchema), + }, + parameters: getRecordArray(subscriptionConstructor, 'parameters').map(normalizeParameterSchema), + }, + subscription_schema: getRecordArray(value, 'subscription_schema').map(normalizeParameterSchema), + } +} + +const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): PluginDeclaration => { + const { declaration } = plugin + return { + plugin_unique_identifier: plugin.plugin_unique_identifier, + version: declaration.version, + author: declaration.author ?? '', + icon: declaration.icon, + icon_dark: declaration.icon_dark ?? undefined, + name: declaration.name, + category: normalizePluginCategory(declaration.category), + label: normalizeI18nObject(declaration.label, declaration.name), + description: normalizeI18nObject(declaration.description, declaration.name), + created_at: declaration.created_at, + resource: declaration.resource, + plugins: declaration.plugins, + verified: declaration.verified ?? false, + endpoint: normalizePluginEndpointDeclaration(declaration.endpoint), + tool: normalizePluginToolDeclaration(declaration.tool), + datasource: normalizePluginToolDeclaration(declaration.datasource), + model: declaration.model, + tags: declaration.tags ?? [], + agent_strategy: declaration.agent_strategy, + meta: { + version: getString(declaration.meta.version) || declaration.version, + minimum_dify_version: getString(declaration.meta.minimum_dify_version) || undefined, + }, + trigger: normalizePluginTriggerDeclaration(declaration.trigger, declaration.name), + } +} + +export const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemResponse): PluginDetail => { + const declaration = normalizePluginDeclaration(plugin) + + return { + id: plugin.id, + created_at: plugin.created_at, + updated_at: plugin.updated_at, + name: declaration.name, + plugin_id: plugin.plugin_id, + plugin_unique_identifier: plugin.plugin_unique_identifier, + declaration, + installation_id: plugin.id, + tenant_id: plugin.tenant_id, + endpoints_setups: plugin.endpoints_setups, + endpoints_active: plugin.endpoints_active, + version: plugin.version, + latest_version: plugin.version, + latest_unique_identifier: plugin.plugin_unique_identifier, + source: normalizePluginSource(plugin.source), + meta: normalizePluginMeta(plugin.meta), + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + } +} + const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({ @@ -114,10 +529,13 @@ export const useCheckInstalled = ({ pluginIds: string[] enabled: boolean }) => { - return useQuery(consoleQuery.plugins.checkInstalled.queryOptions({ + return useQuery(consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({ input: { body: { plugin_ids: pluginIds } }, enabled, staleTime: 0, + select: response => ({ + plugins: response.plugins.map(normalizeInstalledPluginDetail), + }), })) } @@ -125,7 +543,7 @@ export const useInvalidateCheckInstalled = () => { const queryClient = useQueryClient() return () => { queryClient.invalidateQueries({ - queryKey: consoleQuery.plugins.checkInstalled.key(), + queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(), }) } }