Compare commits

..

1 Commits

Author SHA1 Message Date
eux
bf46b82303 fix(web): clarify unpublished explore app handling (#38260) 2026-07-01 11:04:01 +00:00
22 changed files with 248 additions and 401 deletions

View File

@ -31,7 +31,7 @@ from controllers.console.wraps import (
with_current_user_id,
)
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginInstallation, PluginInstallationSource
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
@ -309,8 +309,6 @@ class PluginInstallationItemResponse(ResponseModel):
id: str
created_at: datetime
updated_at: datetime
name: str
installation_id: str
tenant_id: str
endpoints_setups: int
endpoints_active: int
@ -320,45 +318,14 @@ class PluginInstallationItemResponse(ResponseModel):
plugin_id: str
plugin_unique_identifier: str
version: str
latest_version: str
latest_unique_identifier: str
status: Literal["active", "deleted"]
deprecated_reason: str
alternative_plugin_id: str
checksum: str
declaration: PluginDeclarationResponse
declaration: Mapping[str, Any]
class PluginInstallationsResponse(ResponseModel):
plugins: list[PluginInstallationItemResponse]
def _plugin_installation_response(plugin: PluginInstallation) -> PluginInstallationItemResponse:
return PluginInstallationItemResponse(
id=plugin.id,
created_at=plugin.created_at,
updated_at=plugin.updated_at,
name=plugin.declaration.name,
installation_id=plugin.id,
tenant_id=plugin.tenant_id,
endpoints_setups=plugin.endpoints_setups,
endpoints_active=plugin.endpoints_active,
runtime_type=plugin.runtime_type,
source=plugin.source,
meta=plugin.meta,
plugin_id=plugin.plugin_id,
plugin_unique_identifier=plugin.plugin_unique_identifier,
version=plugin.version,
latest_version=plugin.version,
latest_unique_identifier=plugin.plugin_unique_identifier,
status="active",
deprecated_reason="",
alternative_plugin_id="",
checksum=plugin.checksum,
declaration=PluginDeclarationResponse.model_validate(jsonable_encoder(plugin.declaration)),
)
class PluginManifestResponse(ResponseModel):
manifest: Any
@ -621,10 +588,7 @@ class PluginListInstallationsFromIdsApi(Resource):
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
return dump_response(
PluginInstallationsResponse,
{"plugins": [_plugin_installation_response(plugin) for plugin in plugins]},
)
return jsonable_encoder({"plugins": plugins})
@console_ns.route("/workspaces/current/plugin/icon")

View File

@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Any
from pydantic import BaseModel, Field, computed_field, model_validator
@ -32,9 +32,7 @@ class MarketplacePluginDeclaration(BaseModel):
latest_package_identifier: str = Field(
..., description="Unique identifier for the latest package release of the plugin"
)
status: Literal["active", "deleted"] = Field(
..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`"
)
status: str = 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, Literal
from typing import Any, ClassVar
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: Literal["active", "deleted"]
status: str
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, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
| status | string | | Yes |
| unique_identifier | string | | Yes |
| version | string | | Yes |
@ -19588,24 +19588,17 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| alternative_plugin_id | string | | Yes |
| checksum | string | | Yes |
| created_at | dateTime | | Yes |
| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes |
| deprecated_reason | string | | Yes |
| declaration | object | | Yes |
| endpoints_active | integer | | Yes |
| endpoints_setups | integer | | Yes |
| id | string | | Yes |
| installation_id | string | | Yes |
| latest_unique_identifier | string | | Yes |
| latest_version | string | | Yes |
| meta | object | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| runtime_type | string | | Yes |
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
| status | string, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
| tenant_id | string | | Yes |
| updated_at | dateTime | | Yes |
| version | string | | Yes |

View File

@ -42,7 +42,6 @@ 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
@ -479,19 +478,12 @@ class TestPluginListInstallationsFromIdsApi:
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.plugin.PluginService.list_installations_from_ids",
return_value=[PluginInstallation.model_validate(_plugin_category_list_item())],
return_value=[{"id": "p1"}],
),
):
result = method(api, "t1")
assert result["plugins"][0]["id"] == "entity-1"
assert result["plugins"][0]["name"] == "test-plugin"
assert result["plugins"][0]["installation_id"] == "entity-1"
assert result["plugins"][0]["latest_version"] == "1.0.0"
assert result["plugins"][0]["latest_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
assert result["plugins"][0]["status"] == "active"
assert result["plugins"][0]["deprecated_reason"] == ""
assert result["plugins"][0]["alternative_plugin_id"] == ""
assert "plugins" in result
def test_daemon_error(self, app: Flask):
api = PluginListInstallationsFromIdsApi()

View File

@ -1132,26 +1132,21 @@ export type PluginAutoUpgradeSettingsResponseModel = {
}
export type PluginInstallationItemResponse = {
alternative_plugin_id: string
checksum: string
created_at: string
declaration: PluginDeclarationResponse
deprecated_reason: string
declaration: {
[key: string]: unknown
}
endpoints_active: number
endpoints_setups: number
id: string
installation_id: string
latest_unique_identifier: string
latest_version: string
meta: {
[key: string]: unknown
}
name: string
plugin_id: string
plugin_unique_identifier: string
runtime_type: string
source: PluginInstallationSource
status: 'active' | 'deleted'
tenant_id: string
updated_at: string
version: string
@ -1161,7 +1156,7 @@ export type LatestPluginCache = {
alternative_plugin_id: string
deprecated_reason: string
plugin_id: string
status: 'active' | 'deleted'
status: string
unique_identifier: string
version: string
}
@ -1484,6 +1479,39 @@ 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
@ -1524,39 +1552,6 @@ 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

@ -1114,7 +1114,7 @@ export const zLatestPluginCache = z.object({
alternative_plugin_id: z.string(),
deprecated_reason: z.string(),
plugin_id: z.string(),
status: z.enum(['active', 'deleted']),
status: z.string(),
unique_identifier: z.string(),
version: z.string(),
})
@ -1729,6 +1729,33 @@ 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
*
@ -2288,40 +2315,6 @@ export const zPluginDeclarationResponse = z.object({
version: z.string(),
})
/**
* PluginInstallationItemResponse
*/
export const zPluginInstallationItemResponse = z.object({
alternative_plugin_id: z.string(),
checksum: z.string(),
created_at: z.iso.datetime(),
declaration: zPluginDeclarationResponse,
deprecated_reason: z.string(),
endpoints_active: z.int(),
endpoints_setups: z.int(),
id: z.string(),
installation_id: z.string(),
latest_unique_identifier: z.string(),
latest_version: z.string(),
meta: z.record(z.string(), z.unknown()),
name: z.string(),
plugin_id: z.string(),
plugin_unique_identifier: z.string(),
runtime_type: z.string(),
source: zPluginInstallationSource,
status: z.enum(['active', 'deleted']),
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

@ -215,7 +215,7 @@ describe('App Publisher Flow', () => {
fireEvent.click(screen.getByText('common.openInExplore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
expect(mockToastError).toHaveBeenCalledWith('notPublishedYet')
})
})
})

View File

@ -562,7 +562,7 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
expect(mockToastError).toHaveBeenCalledWith('notPublishedYet')
})
})

View File

@ -231,7 +231,7 @@ export function AppPublisher({
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
if (installed_apps?.length > 0)
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
throw new Error('No app found in Explore')
throw new Error(t('notPublishedYet', { ns: 'app' }))
}, {
onError: (err) => {
toast.error(`${err.message || err}`)

View File

@ -14,6 +14,10 @@ import { StarredAppCard } from '../starred-app-card'
let mockWebappAuthEnabled = false
let mockRbacEnabled = true
const mockUserCanAccessApp = vi.hoisted(() => ({
result: true as boolean | undefined,
isLoading: false,
}))
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: {
@ -118,15 +122,15 @@ vi.mock('@/service/explore', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result },
isLoading: mockUserCanAccessApp.isLoading,
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result },
isLoading: mockUserCanAccessApp.isLoading,
}),
}))
@ -380,6 +384,8 @@ describe('AppCard', () => {
mockOpenAsyncWindow.mockReset()
mockWebappAuthEnabled = false
mockRbacEnabled = true
mockUserCanAccessApp.result = true
mockUserCanAccessApp.isLoading = false
mockDeleteMutationPending = false
mockToggleStarMutationPending = false
mockAppContext.isCurrentWorkspaceEditor = true
@ -1733,6 +1739,28 @@ describe('AppCard', () => {
})
describe('Open in Explore - No App Found', () => {
it('should tell workflow users to publish before opening in explore', async () => {
const workflowApp = createMockApp({
mode: AppModeEnum.WORKFLOW,
workflow: undefined,
})
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.openInExplore'))
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'error',
message: 'app.notPublishedYet',
})
})
it('should handle case when installed_apps is empty array', async () => {
(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] })
@ -1756,6 +1784,10 @@ describe('AppCard', () => {
await waitFor(() => {
expect(exploreService.fetchInstalledAppList).toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'error',
message: 'app.notPublishedYet',
})
})
})
@ -1944,6 +1976,31 @@ describe('AppCard', () => {
})
})
it('should keep open in explore visible for unpublished workflow apps while access check is pending', async () => {
mockUserCanAccessApp.result = false
mockUserCanAccessApp.isLoading = true
const workflowApp = createMockApp({
mode: AppModeEnum.WORKFLOW,
workflow: undefined,
})
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.openInExplore'))
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'error',
message: 'app.notPublishedYet',
})
})
it('should close access control modal when onClose is called', async () => {
render(<AppCard app={mockApp} />)

View File

@ -90,6 +90,11 @@ const ACCESS_MODE_LABEL_KEYS = {
[AccessMode.EXTERNAL_MEMBERS]: 'accessItemsDescription.external',
} as const
const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppModeEnum>([
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
])
type AppCardProps = {
app: App
onlineUsers?: WorkflowOnlineUser[]
@ -103,6 +108,10 @@ type AppAccessModeIconProps = {
const getAppResourceMaintainer = (app: App) => app.maintainer
function requiresPublishedWorkflowInExplore(app: App) {
return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode)
}
function AppAccessModeIcon({ accessMode }: AppAccessModeIconProps) {
const { t } = useTranslation()
@ -182,12 +191,17 @@ function AppCardOperationsMenu({
async function handleOpenInstalledApp(e: MouseEvent<HTMLElement>) {
e.stopPropagation()
e.preventDefault()
if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) {
toast.error(t('notPublishedYet', { ns: 'app' }))
return
}
try {
await openAsyncWindow(async () => {
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
throw new Error('No app found in Explore')
throw new Error(t('notPublishedYet', { ns: 'app' }))
}, {
onError: (err) => {
toast.error(`${err.message || err}`)
@ -272,10 +286,12 @@ function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps)
appId: props.app.id,
enabled: systemFeatures.webapp_auth.enabled,
})
const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id
const shouldShowOpenInExploreOption = !props.app.has_draft_trigger
&& (
!systemFeatures.webapp_auth.enabled
needsPublishBeforeExplore
|| !systemFeatures.webapp_auth.enabled
|| (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))
)

View File

@ -84,9 +84,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({
data: { plugins: [] },
}),
usePluginAutoUpgradeSettings: () => ({
data: {
category: 'model',
@ -114,40 +111,25 @@ vi.mock('@/app/components/plugins/reference-setting-modal', () => ({
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalWorkspaces = actual.consoleQuery.workspaces
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'workspaces') {
if (prop === 'plugins') {
return {
...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(() => {}),
}),
},
},
},
},
...originalPlugins,
checkInstalled: {
queryOptions: () => ({
queryKey: ['plugins', 'checkInstalled'],
queryFn: () => new Promise(() => {}),
}),
},
latestVersions: {
queryOptions: () => ({
queryKey: ['plugins', 'latestVersions'],
queryFn: () => new Promise(() => {}),
}),
},
}
}

View File

@ -163,9 +163,6 @@ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useCheckInstalled: () => ({
data: { plugins: [] },
}),
usePluginAutoUpgradeSettings: () => ({
data: mockReferenceSetting.auto_upgrade
? {
@ -233,40 +230,25 @@ 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 originalWorkspaces = actual.consoleQuery.workspaces
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'workspaces') {
if (prop === 'plugins') {
return {
...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(() => {}),
}),
},
},
},
},
...originalPlugins,
checkInstalled: {
queryOptions: () => ({
queryKey: ['plugins', 'checkInstalled'],
queryFn: () => new Promise(() => {}),
}),
},
latestVersions: {
queryOptions: () => ({
queryKey: ['plugins', 'latestVersions'],
queryFn: () => new Promise(() => {}),
}),
},
}
}

View File

@ -3,7 +3,7 @@ import type {
ModelProvider,
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, 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 { useCheckInstalled } from '@/service/use-plugins'
import { consoleQuery } from '@/service/client'
import UpdateSettingDialog from '../update-setting-dialog'
import {
CustomConfigurationStatusEnum,
@ -64,10 +64,11 @@ const ModelProviderPage = ({
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: allPluginIds } },
enabled: allPluginIds.length > 0,
})
staleTime: 0,
}))
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()

View File

@ -11,17 +11,9 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
plugin: {
list: {
latestVersions: {
post: {
queryOptions: vi.fn((options: unknown) => options),
},
},
},
},
plugins: {
latestVersions: {
queryOptions: vi.fn((options: unknown) => options),
},
},
},
@ -63,7 +55,7 @@ describe('usePluginsWithLatestVersion', () => {
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: [] } },
enabled: false,
})
@ -117,7 +109,7 @@ describe('usePluginsWithLatestVersion', () => {
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({
expect(consoleQuery.plugins.latestVersions.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.workspaces.current.plugin.list.latestVersions.post.queryOptions({
const { data: latestVersionData } = useQuery(consoleQuery.plugins.latestVersions.queryOptions({
input: { body: { plugin_ids: marketplacePluginIds } },
enabled: !!marketplacePluginIds.length,
}))

View File

@ -450,6 +450,18 @@ 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

@ -0,0 +1,32 @@
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,
}

View File

@ -48,6 +48,7 @@ import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.
import { rbacAccessConfigContract } from './console/access-control'
import { exploreRouterContract } from './console/explore'
import { modelProvidersRouterContract } from './console/model-providers'
import { pluginsRouterContract } from './console/plugins'
import { snippetsRouterContract } from './console/snippets'
import { triggersRouterContract } from './console/trigger'
import { trialAppsRouterContract } from './console/try-app'
@ -106,6 +107,7 @@ export const consoleRouterContract = {
...communityContract,
explore: exploreRouterContract,
modelProviders: modelProvidersRouterContract,
plugins: pluginsRouterContract,
rbacAccessConfig: rbacAccessConfigContract,
snippets: snippetsRouterContract,
triggers: triggersRouterContract,

View File

@ -16,6 +16,7 @@ const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRout
explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)),
modelProviders: () =>
import('@/contract/console/model-providers').then(({ modelProvidersRouterContract }) => wrapConsoleContract('modelProviders', modelProvidersRouterContract)),
plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)),
rbacAccessConfig: () =>
import('@/contract/console/access-control').then(({ rbacAccessConfigContract }) => wrapConsoleContract('rbacAccessConfig', rbacAccessConfigContract)),
snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)),

View File

@ -1,4 +1,3 @@
import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query'
import type {
FormOption,
@ -15,12 +14,10 @@ import type {
InstalledPluginListWithTotalResponse,
InstallPackageResponse,
InstallStatusResponse,
MetaData,
PackageDependency,
Permissions,
Plugin,
PluginDeclaration,
PluginDetail,
PluginInfoFromMarketPlace,
PluginsFromMarketplaceByInfoResponse,
PluginsFromMarketplaceResponse,
@ -41,7 +38,7 @@ import { cloneDeep } from 'es-toolkit/object'
import { useCallback, useEffect, useRef } from 'react'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import { useAppContext } from '@/context/app-context'
import { fetchModelProviderModelList } from '@/service/common'
import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins'
@ -59,165 +56,6 @@ type PluginTaskListResponse = {
tasks: PluginTask[]
}
const getString = (value: unknown) => {
return typeof value === 'string' ? value : ''
}
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 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 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: undefined,
tool: undefined,
datasource: undefined,
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: createEmptyTrigger(declaration.name),
}
}
const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemResponse): PluginDetail => {
return {
id: plugin.id,
created_at: plugin.created_at,
updated_at: plugin.updated_at,
name: plugin.name,
plugin_id: plugin.plugin_id,
plugin_unique_identifier: plugin.plugin_unique_identifier,
declaration: normalizePluginDeclaration(plugin),
installation_id: plugin.installation_id,
tenant_id: plugin.tenant_id,
endpoints_setups: plugin.endpoints_setups,
endpoints_active: plugin.endpoints_active,
version: plugin.version,
latest_version: plugin.latest_version,
latest_unique_identifier: plugin.latest_unique_identifier,
source: normalizePluginSource(plugin.source),
meta: normalizePluginMeta(plugin.meta),
status: plugin.status,
deprecated_reason: plugin.deprecated_reason,
alternative_plugin_id: plugin.alternative_plugin_id,
}
}
const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running
const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({
@ -276,13 +114,10 @@ export const useCheckInstalled = ({
pluginIds: string[]
enabled: boolean
}) => {
return useQuery(consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
return useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: pluginIds } },
enabled,
staleTime: 0,
select: response => ({
plugins: response.plugins.map(normalizeInstalledPluginDetail),
}),
}))
}
@ -290,7 +125,7 @@ export const useInvalidateCheckInstalled = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
queryKey: consoleQuery.plugins.checkInstalled.key(),
})
}
}