refactor(web): migrate plugin console contracts (#38252)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou
2026-07-01 23:34:52 +08:00
committed by GitHub
parent 6eb9928df7
commit 37bcb60c26
21 changed files with 798 additions and 177 deletions

View File

@ -319,7 +319,7 @@ class PluginInstallationItemResponse(ResponseModel):
plugin_unique_identifier: str
version: str
checksum: str
declaration: Mapping[str, Any]
declaration: PluginDeclarationResponse
class PluginInstallationsResponse(ResponseModel):

View File

@ -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)"
)

View File

@ -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

View File

@ -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, <br>**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 |

View File

@ -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()

View File

@ -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<string>
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<string>
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

View File

@ -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
*/

View File

@ -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<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
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(() => {}),
}),
},
},
},
},
},
}
}

View File

@ -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<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
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(() => {}),
}),
},
},
},
},
},
}
}

View File

@ -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<string, PluginDetail>()

View File

@ -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,
})

View File

@ -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,
}))

View File

@ -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
}

View File

@ -158,3 +158,7 @@ export const exploreRouterContract = {
installedAppMeta: exploreInstalledAppMetaContract,
banners: exploreBannersContract,
}
export const exploreConsoleRouterContract = {
explore: exploreRouterContract,
}

View File

@ -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<InstalledLatestVersionResponse>())
export const pluginsRouterContract = {
checkInstalled: pluginCheckInstalledContract,
latestVersions: pluginLatestVersionsContract,
}
export const pluginsConsoleRouterContract = {}

View File

@ -370,3 +370,7 @@ export const snippetsRouterContract = {
runDraftWorkflow: runSnippetDraftWorkflowContract,
stopWorkflowTask: stopSnippetWorkflowTaskContract,
}
export const snippetsConsoleRouterContract = {
snippets: snippetsRouterContract,
}

View File

@ -63,3 +63,7 @@ export const trialAppsRouterContract = {
parameters: trialAppParametersContract,
workflows: trialAppWorkflowsContract,
}
export const trialAppsConsoleRouterContract = {
trialApps: trialAppsRouterContract,
}

View File

@ -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,
}

View File

@ -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()

View File

@ -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<string, unknown>
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<AnyContractRouter>
}
const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRouter>> = {
const customConsoleContractLoaders: Record<string, () => Promise<ConsoleContractExtension>> = {
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)

View File

@ -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<string, unknown> => {
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<string, unknown>): MetaData => {
return {
repo: getString(meta.repo),
version: getString(meta.version),
package: getString(meta.package),
}
}
const normalizeToolCredentialOption = (
option: Record<string, unknown>,
): NonNullable<NonNullable<PluginDeclaration['tool']>['credentials_schema'][number]['options']>[number] => ({
label: normalizeI18nObject(getRecord(option, 'label'), getString(option.value)),
value: getString(option.value),
})
const normalizeToolCredential = (
credential: Record<string, unknown>,
): NonNullable<PluginDeclaration['tool']>['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<string, unknown>,
): NonNullable<PluginDeclaration['trigger']['subscription_schema'][number]['options']>[number] => ({
value: getString(option.value),
label: normalizeI18nObject(getRecord(option, 'label'), getString(option.value)),
icon: getString(option.icon) || undefined,
})
const normalizeParameterSchema = (
parameter: Record<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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(),
})
}
}