Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad44b01ae1 | ||
|
|
baa8d2c358 | ||
|
|
b7885fb83a | ||
|
|
46c5093d3e | ||
|
|
c06fba49eb | ||
|
|
41aa553461 | ||
|
|
f3593fcbd2 | ||
|
|
1738693484 | ||
|
|
9134443fda | ||
|
|
e91bb0cc94 | ||
|
|
08a10f7593 | ||
|
|
08a7a17573 | ||
|
|
d4a8111c34 | ||
|
|
2ff450fde5 | ||
|
|
5a148aaeea | ||
|
|
36d36954e6 | ||
|
|
19b6b936e3 |
@@ -1,5 +1,5 @@
|
||||
import io
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
@@ -43,6 +43,7 @@ from core.plugin.entities.plugin_daemon import PluginDecodeResponse, PluginInsta
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
from core.tools.entities.api_entities import ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.tool_manager import ToolManager
|
||||
@@ -93,6 +94,15 @@ class ParserList(BaseModel):
|
||||
class PluginCategoryListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
query: str = Field(default="", max_length=256, description="Case-insensitive search query")
|
||||
tags: list[str] = Field(default_factory=list, max_length=128, description="Match any plugin tag")
|
||||
language: Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"] = Field(
|
||||
default="en_US", description="Language used for localized label and description search"
|
||||
)
|
||||
|
||||
|
||||
class PluginInstalledIdsQuery(BaseModel):
|
||||
category: PluginCategory = Field(description="Plugin category to include")
|
||||
|
||||
|
||||
class ParserLatest(BaseModel):
|
||||
@@ -325,6 +335,10 @@ class PluginListResponse(ResponseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsResponse(ResponseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginVersionsResponse(ResponseModel):
|
||||
versions: Mapping[str, PluginService.LatestPluginCache | None]
|
||||
|
||||
@@ -384,6 +398,7 @@ register_schema_models(
|
||||
console_ns,
|
||||
ParserList,
|
||||
PluginCategoryListQuery,
|
||||
PluginInstalledIdsQuery,
|
||||
PluginAutoUpgradeSettingsPayload,
|
||||
PluginPermissionSettingsPayload,
|
||||
ParserLatest,
|
||||
@@ -420,6 +435,7 @@ register_response_schema_models(
|
||||
PluginDebuggingKeyResponse,
|
||||
PluginDynamicOptionsResponse,
|
||||
PluginInstallationsResponse,
|
||||
PluginInstalledIdsResponse,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
PluginManifestResponse,
|
||||
@@ -477,7 +493,39 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
|
||||
return content
|
||||
|
||||
|
||||
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
|
||||
def _localized_builtin_tool_text(value: I18nObject, language: str) -> str:
|
||||
return getattr(value, language, None) or value.en_US
|
||||
|
||||
|
||||
def _builtin_tool_provider_matches_filters(
|
||||
provider: ToolProviderApiEntity,
|
||||
*,
|
||||
query: str,
|
||||
tags: Sequence[str],
|
||||
language: str,
|
||||
) -> bool:
|
||||
if tags and not any(tag in provider.labels for tag in tags):
|
||||
return False
|
||||
if not query:
|
||||
return True
|
||||
|
||||
lower_query = query.lower()
|
||||
candidates = (
|
||||
provider.name,
|
||||
_localized_builtin_tool_text(provider.label, language),
|
||||
_localized_builtin_tool_text(provider.description, language),
|
||||
)
|
||||
return any(lower_query in candidate.lower() for candidate in candidates)
|
||||
|
||||
|
||||
def _list_hardcoded_builtin_tool_providers(
|
||||
tenant_id: str,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: str = "en_US",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List builtin providers using the same search and tag semantics as category plugins."""
|
||||
db_builtin_providers = {
|
||||
str(ToolProviderID(provider.provider)): provider
|
||||
for provider in ToolManager.list_default_builtin_providers(tenant_id)
|
||||
@@ -498,6 +546,13 @@ def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any
|
||||
db_provider=db_builtin_providers.get(provider.entity.identity.name),
|
||||
decrypt_credentials=False,
|
||||
)
|
||||
if not _builtin_tool_provider_matches_filters(
|
||||
user_provider,
|
||||
query=query,
|
||||
tags=tags,
|
||||
language=language,
|
||||
):
|
||||
continue
|
||||
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
|
||||
builtin_providers.append(user_provider)
|
||||
|
||||
@@ -552,7 +607,9 @@ class PluginCategoryListApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, category: str):
|
||||
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
args = PluginCategoryListQuery.model_validate(
|
||||
{**request.args.to_dict(flat=True), "tags": request.args.getlist("tags")}
|
||||
)
|
||||
|
||||
try:
|
||||
plugin_category = PluginCategory(category)
|
||||
@@ -560,13 +617,26 @@ class PluginCategoryListApi(Resource):
|
||||
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
|
||||
|
||||
try:
|
||||
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
|
||||
plugins = PluginService.list_by_category(
|
||||
tenant_id,
|
||||
plugin_category,
|
||||
args.page,
|
||||
args.page_size,
|
||||
query=args.query,
|
||||
tags=args.tags,
|
||||
language=args.language,
|
||||
)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
builtin_tools = []
|
||||
if plugin_category == PluginCategory.Tool:
|
||||
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
|
||||
builtin_tools = _list_hardcoded_builtin_tool_providers(
|
||||
tenant_id,
|
||||
query=args.query,
|
||||
tags=args.tags,
|
||||
language=args.language,
|
||||
)
|
||||
|
||||
return dump_response(
|
||||
PluginCategoryListResponse,
|
||||
@@ -578,6 +648,24 @@ class PluginCategoryListApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/installed-ids")
|
||||
class PluginInstalledIdsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(PluginInstalledIdsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginInstalledIdsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
args = PluginInstalledIdsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
try:
|
||||
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, args.category)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
return dump_response(PluginInstalledIdsResponse, {"plugin_ids": plugin_ids})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
|
||||
class PluginListLatestVersionsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserLatest.__name__])
|
||||
|
||||
@@ -207,6 +207,10 @@ class PluginListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsDaemonResponse(BaseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginListWithoutTotalResponse(BaseModel):
|
||||
list: list[PluginEntity]
|
||||
has_more: bool
|
||||
|
||||
@@ -14,6 +14,7 @@ from core.plugin.entities.plugin import (
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
@@ -68,6 +69,16 @@ class PluginInstaller(BasePluginClient):
|
||||
)
|
||||
return result.list
|
||||
|
||||
def list_installed_plugin_ids(self, tenant_id: str, category: PluginCategory) -> list[str]:
|
||||
"""List all currently installed plugin IDs in one category."""
|
||||
result = self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/installation/ids",
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
params={"category": category.value},
|
||||
)
|
||||
return result.plugin_ids
|
||||
|
||||
def list_plugins_with_total(self, tenant_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
@@ -77,13 +88,28 @@ class PluginInstaller(BasePluginClient):
|
||||
)
|
||||
|
||||
def list_plugins_by_category(
|
||||
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
self,
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
page: int,
|
||||
page_size: int,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: str = "en_US",
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/{category.value}/list",
|
||||
PluginListWithoutTotalResponse,
|
||||
params={"page": page, "page_size": page_size, "response_type": "paged"},
|
||||
params={
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"response_type": "paged",
|
||||
"query": query,
|
||||
"tags": list(tags),
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
|
||||
def upload_pkg(
|
||||
|
||||
@@ -656,6 +656,12 @@ class PluginService:
|
||||
plugins = manager.list_plugins(tenant_id)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def list_installed_plugin_ids(tenant_id: str, category: PluginCategory) -> Sequence[str]:
|
||||
"""List all currently installed plugin IDs in one category through the daemon's lightweight query."""
|
||||
manager = PluginInstaller()
|
||||
return manager.list_installed_plugin_ids(tenant_id, category)
|
||||
|
||||
@staticmethod
|
||||
def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
"""List tenant plugins with endpoint counts reconciled from live records.
|
||||
@@ -672,17 +678,32 @@ class PluginService:
|
||||
|
||||
@staticmethod
|
||||
def list_by_category(
|
||||
tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
page: int,
|
||||
page_size: int,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: str = "en_US",
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
"""
|
||||
List plugins in one category with a has-more cursor signal and without calculating total.
|
||||
|
||||
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
|
||||
This keeps pagination usable before category is persisted on installation rows.
|
||||
The daemon applies category, search, and tag filters before pagination, then stops once it finds one extra
|
||||
match. Filtered model reads are partial views and therefore do not reconcile the full model-provider cache.
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
|
||||
if category == PluginCategory.Model:
|
||||
plugins = manager.list_plugins_by_category(
|
||||
tenant_id,
|
||||
category,
|
||||
page,
|
||||
page_size,
|
||||
query=query,
|
||||
tags=tags,
|
||||
language=language,
|
||||
)
|
||||
if category == PluginCategory.Model and not query and not tags:
|
||||
should_invalidate_model_provider_cache = (
|
||||
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||
tenant_id,
|
||||
|
||||
@@ -11117,6 +11117,19 @@ Returns permission flags that control workspace features like member invitations
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/plugin/installed-ids
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| category | query | Plugin category to include | Yes | string, <br>**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [PluginInstalledIdsResponse](#plugininstalledidsresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/plugin/list
|
||||
#### Parameters
|
||||
|
||||
@@ -11375,8 +11388,11 @@ Returns permission flags that control workspace features like member invitations
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| language | query | Language used for localized label and description search | No | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US |
|
||||
| page | query | Page number | No | integer, <br>**Default:** 1 |
|
||||
| page_size | query | Page size (1-256) | No | integer, <br>**Default:** 256 |
|
||||
| query | query | Case-insensitive search query | No | string |
|
||||
| tags | query | Match any plugin tag | No | [ string ] |
|
||||
| category | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@@ -20312,8 +20328,11 @@ Shared permission levels for resources (datasets, credentials, etc.)
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US | Language used for localized label and description search<br>*Enum:* `"en_US"`, `"ja_JP"`, `"pt_BR"`, `"zh_Hans"` | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number | No |
|
||||
| page_size | integer, <br>**Default:** 256 | Page size (1-256) | No |
|
||||
| query | string | Case-insensitive search query | No |
|
||||
| tags | [ string ] | Match any plugin tag | No |
|
||||
|
||||
#### PluginCategoryListResponse
|
||||
|
||||
@@ -20514,6 +20533,18 @@ Shared permission levels for resources (datasets, credentials, etc.)
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| plugins | [ [PluginInstallationItemResponse](#plugininstallationitemresponse) ] | | Yes |
|
||||
|
||||
#### PluginInstalledIdsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| category | [PluginCategory](#plugincategory) | Plugin category to include | Yes |
|
||||
|
||||
#### PluginInstalledIdsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| plugin_ids | [ string ] | | Yes |
|
||||
|
||||
#### PluginListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -28,6 +28,7 @@ from controllers.console.workspace.plugin import (
|
||||
PluginFetchMarketplacePkgApi,
|
||||
PluginFetchPermissionApi,
|
||||
PluginIconApi,
|
||||
PluginInstalledIdsApi,
|
||||
PluginInstallFromGithubApi,
|
||||
PluginInstallFromMarketplaceApi,
|
||||
PluginInstallFromPkgApi,
|
||||
@@ -41,12 +42,16 @@ from controllers.console.workspace.plugin import (
|
||||
PluginUploadFromBundleApi,
|
||||
PluginUploadFromGithubApi,
|
||||
PluginUploadFromPkgApi,
|
||||
_list_hardcoded_builtin_tool_providers,
|
||||
)
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallation
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.entities.api_entities import ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from models.account import (
|
||||
Account,
|
||||
TenantAccountRole,
|
||||
@@ -445,7 +450,7 @@ class TestPluginCategoryListApi:
|
||||
mock_list = MagicMock(list=[plugin_item], has_more=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?page=2&page_size=10"),
|
||||
app.test_request_context("/?page=2&page_size=10&query=weather&tags=search&tags=rag&language=zh_Hans"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
|
||||
) as list_mock,
|
||||
@@ -456,18 +461,75 @@ class TestPluginCategoryListApi:
|
||||
):
|
||||
result = method(api, "t1", "tool")
|
||||
|
||||
list_mock.assert_called_once()
|
||||
assert list_mock.call_args.args[0] == "t1"
|
||||
assert list_mock.call_args.args[1] == "tool"
|
||||
assert list_mock.call_args.args[2] == 2
|
||||
assert list_mock.call_args.args[3] == 10
|
||||
list_mock.assert_called_once_with(
|
||||
"t1",
|
||||
"tool",
|
||||
2,
|
||||
10,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
assert result["plugins"][0]["id"] == "entity-1"
|
||||
assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
|
||||
assert result["builtin_tools"][0]["id"] == "builtin"
|
||||
assert result["builtin_tools"][0]["type"] == "builtin"
|
||||
assert result["has_more"] is True
|
||||
assert "total" not in result
|
||||
builtin_mock.assert_called_once_with("t1")
|
||||
builtin_mock.assert_called_once_with(
|
||||
"t1",
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
def test_builtin_tool_providers_use_the_category_list_filters(self):
|
||||
search_provider = ToolProviderApiEntity(
|
||||
id="search-provider",
|
||||
author="dify",
|
||||
name="search-provider",
|
||||
description=I18nObject(en_US="Search provider", zh_Hans="搜索工具"),
|
||||
icon="icon.svg",
|
||||
label=I18nObject(en_US="Search", zh_Hans="搜索"),
|
||||
type=ToolProviderType.BUILT_IN,
|
||||
labels=["search"],
|
||||
)
|
||||
rag_provider = ToolProviderApiEntity(
|
||||
id="rag-provider",
|
||||
author="dify",
|
||||
name="rag-provider",
|
||||
description=I18nObject(en_US="RAG provider", zh_Hans="知识库工具"),
|
||||
icon="icon.svg",
|
||||
label=I18nObject(en_US="RAG", zh_Hans="知识库"),
|
||||
type=ToolProviderType.BUILT_IN,
|
||||
labels=["rag"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("controllers.console.workspace.plugin.ToolManager.list_default_builtin_providers", return_value=[]),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.ToolManager.list_hardcoded_providers",
|
||||
return_value=[MagicMock(), MagicMock()],
|
||||
),
|
||||
patch("controllers.console.workspace.plugin.is_filtered", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.ToolTransformService.builtin_provider_to_user_provider",
|
||||
side_effect=[search_provider, rag_provider],
|
||||
),
|
||||
patch("controllers.console.workspace.plugin.ToolTransformService.repack_provider"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.BuiltinToolProviderSort.sort",
|
||||
side_effect=lambda providers: providers,
|
||||
),
|
||||
):
|
||||
result = _list_hardcoded_builtin_tool_providers(
|
||||
"t1",
|
||||
query="搜索",
|
||||
tags=["search", "weather"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
assert [provider["id"] for provider in result] == ["search-provider"]
|
||||
|
||||
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
|
||||
api = PluginCategoryListApi()
|
||||
@@ -730,6 +792,39 @@ class TestPluginListInstallationsFromIdsApi:
|
||||
assert result == ({"code": "plugin_error", "message": "error"}, 400)
|
||||
|
||||
|
||||
class TestPluginInstalledIdsApi:
|
||||
def test_success(self, app: Flask):
|
||||
api = PluginInstalledIdsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?category=tool"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
|
||||
return_value=["langgenius/openai", "langgenius/anthropic"],
|
||||
) as list_installed_plugin_ids,
|
||||
):
|
||||
result = method(api, "t1")
|
||||
|
||||
assert result == {"plugin_ids": ["langgenius/openai", "langgenius/anthropic"]}
|
||||
list_installed_plugin_ids.assert_called_once_with("t1", PluginCategory.Tool)
|
||||
|
||||
def test_daemon_error(self, app: Flask):
|
||||
api = PluginInstalledIdsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?category=tool"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
|
||||
side_effect=PluginDaemonClientSideError("error"),
|
||||
),
|
||||
):
|
||||
result = method(api, "t1")
|
||||
|
||||
assert result == ({"code": "plugin_error", "message": "error"}, 400)
|
||||
|
||||
|
||||
class TestPluginUploadFromGithubApi:
|
||||
def test_success(self, app: Flask, user):
|
||||
api = PluginUploadFromGithubApi()
|
||||
|
||||
@@ -634,6 +634,11 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
|
||||
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
|
||||
operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"]
|
||||
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
|
||||
assert parameters["query"]["in"] == "query"
|
||||
assert parameters["tags"]["in"] == "query"
|
||||
assert parameters["tags"]["schema"]["type"] == "array"
|
||||
assert parameters["language"]["in"] == "query"
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
@@ -661,3 +666,36 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
|
||||
builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"]
|
||||
for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"):
|
||||
assert field in builtin_tool_schema["properties"]
|
||||
|
||||
|
||||
def test_console_installed_plugin_ids_exported_schema_is_lightweight(tmp_path):
|
||||
from dev.generate_swagger_specs import generate_specs
|
||||
|
||||
written_paths = generate_specs(tmp_path)
|
||||
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
|
||||
operation = payload["paths"]["/workspaces/current/plugin/installed-ids"]["get"]
|
||||
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
|
||||
assert parameters["category"]["in"] == "query"
|
||||
assert parameters["category"]["required"] is True
|
||||
assert parameters["category"]["schema"]["enum"] == [
|
||||
"agent-strategy",
|
||||
"datasource",
|
||||
"extension",
|
||||
"model",
|
||||
"tool",
|
||||
"trigger",
|
||||
]
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
response_schema = payload["components"]["schemas"][response_ref]
|
||||
|
||||
assert response_schema["required"] == ["plugin_ids"]
|
||||
assert response_schema["properties"] == {
|
||||
"plugin_ids": {
|
||||
"items": {"type": "string"},
|
||||
"title": "Plugin Ids",
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ from core.plugin.entities.plugin import (
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginInstallTaskStatus,
|
||||
@@ -132,7 +133,13 @@ class TestPluginDiscovery:
|
||||
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
|
||||
) as mock_request:
|
||||
result = plugin_installer.list_plugins_by_category(
|
||||
"test-tenant", category=PluginCategory.Tool, page=2, page_size=10
|
||||
"test-tenant",
|
||||
category=PluginCategory.Tool,
|
||||
page=2,
|
||||
page_size=10,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
mock_request.assert_called_once()
|
||||
@@ -141,6 +148,9 @@ class TestPluginDiscovery:
|
||||
assert call_args.args[2] is PluginListWithoutTotalResponse
|
||||
assert call_args.kwargs["params"]["page"] == 2
|
||||
assert call_args.kwargs["params"]["page_size"] == 10
|
||||
assert call_args.kwargs["params"]["query"] == "weather"
|
||||
assert call_args.kwargs["params"]["tags"] == ["search", "rag"]
|
||||
assert call_args.kwargs["params"]["language"] == "zh_Hans"
|
||||
assert result.list == [mock_plugin_entity]
|
||||
assert result.has_more is True
|
||||
|
||||
@@ -156,6 +166,23 @@ class TestPluginDiscovery:
|
||||
# Assert: Verify empty list is returned
|
||||
assert len(result) == 0
|
||||
|
||||
def test_list_installed_plugin_ids(self, plugin_installer):
|
||||
"""The lightweight ID endpoint is unpaginated and does not request plugin details."""
|
||||
mock_response = PluginInstalledIdsDaemonResponse(plugin_ids=["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
with patch.object(
|
||||
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
|
||||
) as mock_request:
|
||||
result = plugin_installer.list_installed_plugin_ids("test-tenant", PluginCategory.Tool)
|
||||
|
||||
mock_request.assert_called_once_with(
|
||||
"GET",
|
||||
"plugin/test-tenant/management/installation/ids",
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
params={"category": "tool"},
|
||||
)
|
||||
assert result == ["langgenius/openai", "langgenius/anthropic"]
|
||||
|
||||
def test_fetch_plugin_by_identifier_found(self, plugin_installer):
|
||||
"""Test fetching a plugin by its unique identifier when it exists."""
|
||||
# Arrange: Mock successful fetch
|
||||
|
||||
@@ -806,6 +806,79 @@ class TestPluginListEndpointCounts:
|
||||
assert tool_plugin.endpoints_active == 0
|
||||
|
||||
|
||||
class TestPluginCategoryList:
|
||||
def test_list_by_category_forwards_search_and_tag_filters(self) -> None:
|
||||
plugins = SimpleNamespace(list=[], has_more=False)
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
|
||||
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_by_category(
|
||||
"tenant-1",
|
||||
PluginCategory.Tool,
|
||||
2,
|
||||
25,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
assert result is plugins
|
||||
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
||||
"tenant-1",
|
||||
PluginCategory.Tool,
|
||||
2,
|
||||
25,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
def test_filtered_model_category_does_not_reconcile_from_a_partial_result(self) -> None:
|
||||
plugins = SimpleNamespace(list=[], has_more=False)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||
):
|
||||
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_by_category(
|
||||
"tenant-1",
|
||||
PluginCategory.Model,
|
||||
1,
|
||||
100,
|
||||
query="openai",
|
||||
tags=[],
|
||||
language="en_US",
|
||||
)
|
||||
|
||||
assert result is plugins
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_not_called()
|
||||
|
||||
|
||||
class TestInstalledPluginIds:
|
||||
def test_list_installed_plugin_ids_uses_lightweight_daemon_endpoint(self) -> None:
|
||||
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
|
||||
installer_cls.return_value.list_installed_plugin_ids.return_value = [
|
||||
"langgenius/openai",
|
||||
"langgenius/anthropic",
|
||||
]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_installed_plugin_ids("tenant-1", PluginCategory.Tool)
|
||||
|
||||
assert result == ["langgenius/openai", "langgenius/anthropic"]
|
||||
installer_cls.return_value.list_installed_plugin_ids.assert_called_once_with("tenant-1", PluginCategory.Tool)
|
||||
|
||||
|
||||
class TestPluginModelProviderCacheInvalidation:
|
||||
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
|
||||
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
|
||||
@@ -850,7 +923,13 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
assert result is plugins
|
||||
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
||||
"tenant-1", PluginCategory.Model, 1, 100
|
||||
"tenant-1",
|
||||
PluginCategory.Model,
|
||||
1,
|
||||
100,
|
||||
query="",
|
||||
tags=(),
|
||||
language="en_US",
|
||||
)
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
|
||||
@@ -3498,14 +3498,6 @@
|
||||
"count": 25
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/update-plugin/plugin-version-picker.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -417,6 +417,10 @@ export type ParserPluginIdentifiers = {
|
||||
plugin_unique_identifiers: Array<string>
|
||||
}
|
||||
|
||||
export type PluginInstalledIdsResponse = {
|
||||
plugin_ids: Array<string>
|
||||
}
|
||||
|
||||
export type PluginListResponse = {
|
||||
plugins: Array<PluginEntity>
|
||||
total: number
|
||||
@@ -3574,6 +3578,22 @@ export type PostWorkspacesCurrentPluginInstallPkgResponses = {
|
||||
export type PostWorkspacesCurrentPluginInstallPkgResponse =
|
||||
PostWorkspacesCurrentPluginInstallPkgResponses[keyof PostWorkspacesCurrentPluginInstallPkgResponses]
|
||||
|
||||
export type GetWorkspacesCurrentPluginInstalledIdsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
category: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger'
|
||||
}
|
||||
url: '/workspaces/current/plugin/installed-ids'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentPluginInstalledIdsResponses = {
|
||||
200: PluginInstalledIdsResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentPluginInstalledIdsResponse =
|
||||
GetWorkspacesCurrentPluginInstalledIdsResponses[keyof GetWorkspacesCurrentPluginInstalledIdsResponses]
|
||||
|
||||
export type GetWorkspacesCurrentPluginListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -3887,8 +3907,11 @@ export type GetWorkspacesCurrentPluginByCategoryListData = {
|
||||
category: string
|
||||
}
|
||||
query?: {
|
||||
language?: 'en_US' | 'ja_JP' | 'pt_BR' | 'zh_Hans'
|
||||
page?: number
|
||||
page_size?: number
|
||||
query?: string
|
||||
tags?: Array<string>
|
||||
}
|
||||
url: '/workspaces/current/plugin/{category}/list'
|
||||
}
|
||||
|
||||
@@ -283,6 +283,13 @@ export const zParserPluginIdentifiers = z.object({
|
||||
plugin_unique_identifiers: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstalledIdsResponse
|
||||
*/
|
||||
export const zPluginInstalledIdsResponse = z.object({
|
||||
plugin_ids: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* ParserLatest
|
||||
*/
|
||||
@@ -4070,6 +4077,15 @@ export const zPostWorkspacesCurrentPluginInstallPkgBody = zParserPluginIdentifie
|
||||
*/
|
||||
export const zPostWorkspacesCurrentPluginInstallPkgResponse = zPluginInstallTaskStartResponse
|
||||
|
||||
export const zGetWorkspacesCurrentPluginInstalledIdsQuery = z.object({
|
||||
category: z.enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetWorkspacesCurrentPluginInstalledIdsResponse = zPluginInstalledIdsResponse
|
||||
|
||||
export const zGetWorkspacesCurrentPluginListQuery = z.object({
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
page_size: z.int().gte(1).lte(256).optional().default(256),
|
||||
@@ -4240,8 +4256,11 @@ export const zGetWorkspacesCurrentPluginByCategoryListPath = z.object({
|
||||
})
|
||||
|
||||
export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({
|
||||
language: z.enum(['en_US', 'ja_JP', 'pt_BR', 'zh_Hans']).optional().default('en_US'),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
page_size: z.int().gte(1).lte(256).optional().default(256),
|
||||
query: z.string().max(256).optional().default(''),
|
||||
tags: z.array(z.string()).max(128).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
modelProviders: [],
|
||||
refreshModelProviders: noop,
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
|
||||
@@ -47,6 +47,7 @@ const defaultProviderContext = {
|
||||
modelProviders: [],
|
||||
refreshModelProviders: noop,
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
|
||||
+4
-5
@@ -199,16 +199,15 @@ describe('Card Component', () => {
|
||||
// Act
|
||||
render(<Card item={mockItem} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Test Author/))!.not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/test-name/))!.not.toBeInTheDocument()
|
||||
expect(screen.getByText('1.2.0'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Test Label' }))!.toHaveAttribute(
|
||||
'src',
|
||||
'test-icon-url',
|
||||
)
|
||||
const icon = screen.getByRole('img', { name: 'Test Label' })
|
||||
expect(icon).toHaveAttribute('src', 'test-icon-url')
|
||||
expect(icon).toHaveAttribute('loading', 'lazy')
|
||||
expect(icon).toHaveAttribute('decoding', 'async')
|
||||
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/plugin.auth.default/))!.toBeInTheDocument()
|
||||
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import { fireEvent, screen } from '@testing-library/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import {
|
||||
useGetDataSourceListAuth,
|
||||
@@ -301,6 +302,9 @@ describe('DataSourcePage Component', () => {
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-actions-plugin-1')).toBeInTheDocument()
|
||||
expect(useInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter installed data sources and pass search text to marketplace', () => {
|
||||
|
||||
@@ -108,6 +108,8 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
|
||||
alt={providerLabel}
|
||||
width={20}
|
||||
height={20}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,9 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource
|
||||
select: (s) => s.enable_marketplace,
|
||||
})
|
||||
const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth()
|
||||
const { data: installedPluginList } = useInstalledPluginList()
|
||||
const { data: installedPluginList } = useInstalledPluginList({
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
const pluginListWithLatestVersion = usePluginsWithLatestVersion(installedPluginList?.plugins)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
|
||||
|
||||
@@ -293,6 +293,23 @@ describe('hooks', () => {
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep the query disabled when requested', () => {
|
||||
;(useQuery as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useModelList(ModelTypeEnum.textEmbedding, { enabled: false }))
|
||||
|
||||
expect(useQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
queryKey: ['model-list', ModelTypeEnum.textEmbedding],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle loading state', () => {
|
||||
;(useQuery as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
+10
-1
@@ -50,6 +50,7 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
|
||||
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
|
||||
mockProviderContextState: {
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: true,
|
||||
},
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
}))
|
||||
@@ -183,6 +184,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: mockProviders,
|
||||
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
|
||||
isSuccessModelProviders: mockProviderContextState.isSuccessModelProviders,
|
||||
refreshModelProviders: mockRefreshModelProviders,
|
||||
}),
|
||||
}))
|
||||
@@ -402,6 +404,7 @@ describe('ModelProviderPage', () => {
|
||||
mockRefreshModelProviders.mockClear()
|
||||
mockInstalledModelPlugins.value = []
|
||||
mockProviderContextState.isLoadingModelProviders = false
|
||||
mockProviderContextState.isSuccessModelProviders = true
|
||||
mockAutoUpgradeError.value = undefined
|
||||
mockReferenceSetting.auto_upgrade = {
|
||||
strategy_setting: 'latest',
|
||||
@@ -599,8 +602,9 @@ describe('ModelProviderPage', () => {
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: true,
|
||||
})
|
||||
expect(screen.getByTestId('provider-card')).toHaveAttribute(
|
||||
'data-plugin-id',
|
||||
@@ -693,9 +697,14 @@ describe('ModelProviderPage', () => {
|
||||
|
||||
it('should show provider placeholders while model providers are loading', () => {
|
||||
mockProviderContextState.isLoadingModelProviders = true
|
||||
mockProviderContextState.isSuccessModelProviders = false
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('provider-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
|
||||
@@ -74,10 +74,16 @@ export const useLanguage = () => {
|
||||
const locale = useLocale()
|
||||
return locale.replace('-', '_')
|
||||
}
|
||||
export const useModelList = (type: ModelTypeEnum) => {
|
||||
|
||||
type UseModelListOptions = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useModelList = (type: ModelTypeEnum, { enabled = true }: UseModelListOptions = {}) => {
|
||||
const { data, refetch, isPending } = useQuery({
|
||||
queryKey: commonQueryKeys.modelList(type),
|
||||
queryFn: () => fetchModelList(`/workspaces/current/models/model-types/${type}`),
|
||||
enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -62,12 +62,14 @@ const ModelProviderPage = ({
|
||||
const {
|
||||
modelProviders: providers,
|
||||
isLoadingModelProviders,
|
||||
isSuccessModelProviders,
|
||||
refreshModelProviders,
|
||||
} = useProviderContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
|
||||
const { data: installedModelPlugins } = useInstalledPluginList({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: isSuccessModelProviders,
|
||||
})
|
||||
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
|
||||
const pluginDetailMap = useMemo(() => {
|
||||
|
||||
@@ -49,7 +49,15 @@ const ModelIcon: FC<ModelIconProps> = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img alt="model-icon" src={iconUrl} className={iconClassName} />
|
||||
<img
|
||||
alt=""
|
||||
className={iconClassName}
|
||||
decoding="async"
|
||||
height={20}
|
||||
loading="lazy"
|
||||
src={iconUrl}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-1
@@ -1,13 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import StatusIndicators from '../status-indicators'
|
||||
|
||||
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
|
||||
const mockUseInstalledPluginList = vi.fn((_options: unknown) => ({
|
||||
data: { plugins: installedPlugins },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
|
||||
useInstalledPluginList: (options: unknown) => mockUseInstalledPluginList(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
||||
@@ -22,6 +26,7 @@ describe('StatusIndicators', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: { plugins: installedPlugins } })
|
||||
})
|
||||
|
||||
const getPopoverTrigger = (name: string) => {
|
||||
@@ -42,6 +47,10 @@ describe('StatusIndicators', () => {
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
|
||||
@@ -56,6 +65,10 @@ describe('StatusIndicators', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated'))
|
||||
|
||||
@@ -76,6 +89,10 @@ describe('StatusIndicators', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title'))
|
||||
|
||||
@@ -95,6 +112,10 @@ describe('StatusIndicators', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => {
|
||||
@@ -109,6 +130,10 @@ describe('StatusIndicators', () => {
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => {
|
||||
|
||||
+10
-5
@@ -1,7 +1,7 @@
|
||||
import type { SelectorParam } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
|
||||
import Link from '@/next/link'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
@@ -53,7 +53,12 @@ const StatusIndicators = ({
|
||||
pluginInfo,
|
||||
t,
|
||||
}: StatusIndicatorsProps) => {
|
||||
const { data: pluginList } = useInstalledPluginList()
|
||||
const shouldLoadInstalledModelPlugins =
|
||||
!needsConfiguration && modelProvider && disabled && !inModelList && !!pluginInfo
|
||||
const { data: pluginList } = useInstalledPluginList({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: shouldLoadInstalledModelPlugins,
|
||||
})
|
||||
const renderTooltipContent = (
|
||||
title: string,
|
||||
description?: string,
|
||||
@@ -100,7 +105,7 @@ const StatusIndicators = ({
|
||||
ns: 'workflow',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningFill className="size-4 text-text-destructive" />
|
||||
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
|
||||
</StatusPopover>
|
||||
) : !pluginInfo ? (
|
||||
<StatusPopover
|
||||
@@ -112,7 +117,7 @@ const StatusIndicators = ({
|
||||
'/plugins',
|
||||
)}
|
||||
>
|
||||
<RiErrorWarningFill className="size-4 text-text-destructive" />
|
||||
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
|
||||
</StatusPopover>
|
||||
) : (
|
||||
<SwitchPluginVersion
|
||||
@@ -138,7 +143,7 @@ const StatusIndicators = ({
|
||||
'/plugins',
|
||||
)}
|
||||
>
|
||||
<RiErrorWarningFill className="size-4 text-text-destructive" />
|
||||
<span aria-hidden className="i-ri-error-warning-fill size-4 text-text-destructive" />
|
||||
</StatusPopover>
|
||||
)}
|
||||
</>
|
||||
|
||||
+9
-1
@@ -41,7 +41,15 @@ const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-2', className)}>
|
||||
{iconUrl ? (
|
||||
<img alt="provider-icon" src={iconUrl} className="size-6" />
|
||||
<img
|
||||
alt=""
|
||||
className="size-6"
|
||||
decoding="async"
|
||||
height={24}
|
||||
loading="lazy"
|
||||
src={iconUrl}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span aria-hidden className="i-custom-vender-other-group size-4 text-text-tertiary" />
|
||||
|
||||
+40
-3
@@ -1,5 +1,6 @@
|
||||
import type { DefaultModelResponse } from '../../declarations'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ModelTypeEnum } from '../../declarations'
|
||||
@@ -23,6 +24,7 @@ vi.mock('react-i18next', async () => {
|
||||
'modelProvider.ttsModel.tip': 'TTS model tip',
|
||||
'operation.cancel': 'Cancel',
|
||||
'operation.save': 'Save',
|
||||
loading: 'Loading',
|
||||
'actionMsg.modifiedSuccessfully': 'Modified successfully',
|
||||
})
|
||||
})
|
||||
@@ -31,6 +33,7 @@ const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockModelSelectorProps = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
@@ -67,9 +70,7 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useModelList: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useModelList: mockUseModelList,
|
||||
useSystemDefaultModelAndModelList: (defaultModel: DefaultModelResponse | undefined) => [
|
||||
defaultModel || {
|
||||
model: '',
|
||||
@@ -128,6 +129,7 @@ const defaultProps = {
|
||||
describe('SystemModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelList.mockReturnValue({ data: [], isLoading: false })
|
||||
mockModelSelectorProps.length = 0
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config']
|
||||
})
|
||||
@@ -146,6 +148,41 @@ describe('SystemModel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads non-text model lists only after the dialog opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading instead of empty model selectors while model lists load', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseModelList.mockReturnValue({ data: [], isLoading: true })
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Mock Model Selector' })).not.toBeInTheDocument()
|
||||
const saveButton = screen.getByRole('button', { name: /save/i })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
await user.click(saveButton)
|
||||
expect(mockUpdateDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable button when loading', () => {
|
||||
render(<SystemModel {...defaultProps} isLoading />)
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
||||
|
||||
+126
-89
@@ -66,10 +66,22 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config')
|
||||
const updateModelList = useUpdateModelList()
|
||||
const invalidateDefaultModel = useInvalidateDefaultModel()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text)
|
||||
const { data: ttsModelList } = useModelList(ModelTypeEnum.tts)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: embeddingModelList, isLoading: isEmbeddingModelListLoading } = useModelList(
|
||||
ModelTypeEnum.textEmbedding,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: rerankModelList, isLoading: isRerankModelListLoading } = useModelList(
|
||||
ModelTypeEnum.rerank,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: speech2textModelList, isLoading: isSpeech2textModelListLoading } = useModelList(
|
||||
ModelTypeEnum.speech2text,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: ttsModelList, isLoading: isTTSModelListLoading } = useModelList(ModelTypeEnum.tts, {
|
||||
enabled: open,
|
||||
})
|
||||
const [changedModelTypes, setChangedModelTypes] = useState<ModelTypeEnum[]>([])
|
||||
const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] =
|
||||
useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList)
|
||||
@@ -83,7 +95,12 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
ttsDefaultModel,
|
||||
ttsModelList,
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const isSystemModelListLoading =
|
||||
open &&
|
||||
(isEmbeddingModelListLoading ||
|
||||
isRerankModelListLoading ||
|
||||
isSpeech2textModelListLoading ||
|
||||
isTTSModelListLoading)
|
||||
|
||||
const getCurrentDefaultModelByModelType = (modelType: ModelTypeEnum) => {
|
||||
if (modelType === ModelTypeEnum.textGeneration) return currentTextGenerationDefaultModel
|
||||
@@ -105,6 +122,8 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
setChangedModelTypes([...changedModelTypes, modelType])
|
||||
}
|
||||
const handleSave = async () => {
|
||||
if (isSystemModelListLoading) return
|
||||
|
||||
const res = await updateDefaultModel({
|
||||
url: '/workspaces/current/default-model',
|
||||
body: {
|
||||
@@ -187,91 +206,109 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.systemReasoningModel.key',
|
||||
'modelProvider.systemReasoningModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
|
||||
}
|
||||
/>
|
||||
{isSystemModelListLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t(($) => $.loading, { ns: 'common' })}
|
||||
className="flex h-full min-h-48 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-5 animate-spin text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.systemReasoningModel.key',
|
||||
'modelProvider.systemReasoningModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.embeddingModel.key',
|
||||
'modelProvider.embeddingModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.rerankModel.key',
|
||||
'modelProvider.rerankModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.speechToTextModel.key',
|
||||
'modelProvider.speechToTextModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.speech2text, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.embeddingModel.key',
|
||||
'modelProvider.embeddingModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.rerankModel.key', 'modelProvider.rerankModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.speechToTextModel.key',
|
||||
'modelProvider.speechToTextModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-[76px] shrink-0 items-center justify-end gap-2 px-6 pt-5 pb-6">
|
||||
<Button className="min-w-[72px]" onClick={() => setOpen(false)}>
|
||||
@@ -281,7 +318,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
className="min-w-[72px]"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canManageSystemDefaultModel}
|
||||
disabled={!canManageSystemDefaultModel || isSystemModelListLoading}
|
||||
>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
|
||||
@@ -94,8 +94,14 @@ let mockCollectionData: ReturnType<typeof createDefaultCollections> = []
|
||||
let mockIsLoadingToolProviders = false
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUseAllToolProviders = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
|
||||
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: (enabled?: boolean) => mockUseAllToolProviders(enabled),
|
||||
useAllCustomTools: (enabled?: boolean) => mockUseAllCustomTools(enabled),
|
||||
useAllMCPTools: (enabled?: boolean) => mockUseAllMCPTools(enabled),
|
||||
useAllWorkflowTools: (enabled?: boolean) => mockUseAllWorkflowTools(enabled),
|
||||
}))
|
||||
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
@@ -386,6 +392,23 @@ describe('ProviderList', () => {
|
||||
isLoading: enabled ? mockIsLoadingToolProviders : false,
|
||||
refetch: mockRefetch,
|
||||
}))
|
||||
mockUseAllCustomTools.mockImplementation((enabled = true) => ({
|
||||
data: enabled ? mockCollectionData.filter((collection) => collection.type === 'api') : [],
|
||||
isLoading: enabled ? mockIsLoadingToolProviders : false,
|
||||
refetch: mockRefetch,
|
||||
}))
|
||||
mockUseAllMCPTools.mockImplementation((enabled = true) => ({
|
||||
data: enabled ? mockCollectionData.filter((collection) => collection.type === 'mcp') : [],
|
||||
isLoading: enabled ? mockIsLoadingToolProviders : false,
|
||||
refetch: mockRefetch,
|
||||
}))
|
||||
mockUseAllWorkflowTools.mockImplementation((enabled = true) => ({
|
||||
data: enabled
|
||||
? mockCollectionData.filter((collection) => collection.type === 'workflow')
|
||||
: [],
|
||||
isLoading: enabled ? mockIsLoadingToolProviders : false,
|
||||
refetch: mockRefetch,
|
||||
}))
|
||||
mockCheckedInstalledData = null
|
||||
mockCanSetPermissions.mockReturnValue(true)
|
||||
mockReferenceSetting.mockReturnValue({
|
||||
@@ -447,7 +470,10 @@ describe('ProviderList', () => {
|
||||
|
||||
renderProviderList({ category })
|
||||
|
||||
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
|
||||
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
|
||||
expect(mockUseAllCustomTools).toHaveBeenCalledWith(category === 'api')
|
||||
expect(mockUseAllWorkflowTools).toHaveBeenCalledWith(category === 'workflow')
|
||||
expect(mockUseAllMCPTools).toHaveBeenCalledWith(false)
|
||||
expect(screen.getByTestId(cardTestId)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('custom-create-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('toolbar-add-custom-tool')).not.toBeInTheDocument()
|
||||
@@ -940,7 +966,10 @@ describe('ProviderList', () => {
|
||||
|
||||
renderProviderList({ category: 'mcp' })
|
||||
|
||||
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
|
||||
expect(mockUseAllToolProviders).toHaveBeenCalledWith(false)
|
||||
expect(mockUseAllCustomTools).toHaveBeenCalledWith(false)
|
||||
expect(mockUseAllWorkflowTools).toHaveBeenCalledWith(false)
|
||||
expect(mockUseAllMCPTools).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mcp-list')).toHaveAttribute('data-show-create-card', 'false')
|
||||
expect(screen.queryByTestId('toolbar-add-mcp')).not.toBeInTheDocument()
|
||||
|
||||
@@ -33,7 +33,12 @@ import ProviderDetail from '@/app/components/tools/provider/detail'
|
||||
import { ToolProviderGrid } from '@/app/components/tools/tool-provider-grid'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import {
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllToolProviders,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useToolMarketplacePanel } from './hooks/use-tool-marketplace-panel'
|
||||
import { useToolProviderCategory } from './hooks/use-tool-provider-category'
|
||||
import ToolProviderCreateAction from './tool-provider-create-action'
|
||||
@@ -45,6 +50,8 @@ type ProviderListProps = {
|
||||
layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode
|
||||
}
|
||||
|
||||
const EMPTY_COLLECTIONS: Collection[] = []
|
||||
|
||||
type BuiltinMarketplacePanelProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
contentInset: ToolsContentInset
|
||||
@@ -118,11 +125,25 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi
|
||||
const handleCreatedMCPProviderHandled = useCallback(() => {
|
||||
setCreatedMCPProviderId(undefined)
|
||||
}, [])
|
||||
const {
|
||||
data: collectionList = [],
|
||||
isLoading: isCollectionListLoading,
|
||||
refetch,
|
||||
} = useAllToolProviders()
|
||||
const allToolProvidersQuery = useAllToolProviders(activeTab === 'builtin')
|
||||
const customToolsQuery = useAllCustomTools(activeTab === 'api')
|
||||
const workflowToolsQuery = useAllWorkflowTools(activeTab === 'workflow')
|
||||
const mcpToolsQuery = useAllMCPTools(activeTab === 'mcp')
|
||||
const { refetch: refetchMcpTools } = mcpToolsQuery
|
||||
const activeToolsQuery =
|
||||
activeTab === 'api'
|
||||
? customToolsQuery
|
||||
: activeTab === 'workflow'
|
||||
? workflowToolsQuery
|
||||
: activeTab === 'mcp'
|
||||
? mcpToolsQuery
|
||||
: allToolProvidersQuery
|
||||
const collectionList = activeToolsQuery.data ?? EMPTY_COLLECTIONS
|
||||
const isCollectionListLoading = activeToolsQuery.isLoading
|
||||
const refetch = activeToolsQuery.refetch
|
||||
const refreshMcpTools = useCallback(async () => {
|
||||
await refetchMcpTools()
|
||||
}, [refetchMcpTools])
|
||||
const activeTabCollectionList = useMemo(() => {
|
||||
return collectionList.filter((collection) => collection.type === activeTab)
|
||||
}, [activeTab, collectionList])
|
||||
@@ -249,10 +270,13 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi
|
||||
)}
|
||||
{activeTab === 'mcp' && (
|
||||
<MCPList
|
||||
providers={mcpToolsQuery.data ?? []}
|
||||
isLoading={mcpToolsQuery.isLoading}
|
||||
searchText={keywords}
|
||||
contentInset={contentInset}
|
||||
createdProviderId={createdMCPProviderId}
|
||||
showCreateCard={shouldShowMCPCreateCard}
|
||||
onRefresh={refreshMcpTools}
|
||||
onCreatedProviderHandled={handleCreatedMCPProviderHandled}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Icon from '../card-icon'
|
||||
|
||||
describe('Plugin card icon', () => {
|
||||
it('lazy-loads URL icons and hides a failed image', () => {
|
||||
render(<Icon src="https://example.com/plugin-icon.png" />)
|
||||
|
||||
const image = screen.getByAltText('')
|
||||
|
||||
expect(image).toHaveAttribute('src', 'https://example.com/plugin-icon.png')
|
||||
expect(image).toHaveAttribute('loading', 'lazy')
|
||||
expect(image).toHaveAttribute('decoding', 'async')
|
||||
expect(image).toHaveAttribute('width', '40')
|
||||
expect(image).toHaveAttribute('height', '40')
|
||||
|
||||
fireEvent.error(image)
|
||||
|
||||
expect(image).toHaveStyle({ display: 'none' })
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,17 @@ const iconSizeMap = {
|
||||
medium: 'w-9 h-9',
|
||||
large: 'w-10 h-10',
|
||||
}
|
||||
|
||||
const iconPixelSizeMap = {
|
||||
xs: 16,
|
||||
tiny: 24,
|
||||
small: 32,
|
||||
medium: 36,
|
||||
large: 40,
|
||||
}
|
||||
|
||||
type IconSize = keyof typeof iconSizeMap
|
||||
|
||||
const Icon = ({
|
||||
className,
|
||||
src,
|
||||
@@ -27,7 +38,7 @@ const Icon = ({
|
||||
}
|
||||
installed?: boolean
|
||||
installFailed?: boolean
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
|
||||
size?: IconSize
|
||||
}) => {
|
||||
const iconClassName =
|
||||
'flex justify-center items-center gap-2 absolute bottom-[-4px] right-[-4px] w-[18px] h-[18px] rounded-full border-2 border-components-panel-bg'
|
||||
@@ -51,16 +62,19 @@ const Icon = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative shrink-0 rounded-md bg-contain bg-center bg-no-repeat',
|
||||
iconSizeMap[size],
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${src})`,
|
||||
}}
|
||||
>
|
||||
<div className={cn('relative shrink-0 rounded-md', iconSizeMap[size], className)}>
|
||||
<img
|
||||
alt=""
|
||||
className="size-full rounded-md object-contain object-center"
|
||||
decoding="async"
|
||||
height={iconPixelSizeMap[size]}
|
||||
loading="lazy"
|
||||
src={src}
|
||||
width={iconPixelSizeMap[size]}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
{installed && (
|
||||
<div className={cn(iconClassName, 'bg-state-success-solid')}>
|
||||
<RiCheckLine className="size-3 text-text-primary-on-surface" />
|
||||
|
||||
@@ -213,6 +213,10 @@ describe('PluginItem', () => {
|
||||
// Assert
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('alt', `plugin-${plugin.plugin_unique_identifier}-logo`)
|
||||
expect(img).toHaveAttribute('loading', 'lazy')
|
||||
expect(img).toHaveAttribute('decoding', 'async')
|
||||
expect(img).toHaveAttribute('width', '40')
|
||||
expect(img).toHaveAttribute('height', '40')
|
||||
})
|
||||
|
||||
it('should not render category label in corner mark', () => {
|
||||
|
||||
@@ -137,8 +137,12 @@ const PluginItem: FC<Props> = ({
|
||||
<div className="flex size-10 items-center justify-center overflow-hidden rounded-xl border border-components-panel-border-subtle">
|
||||
<img
|
||||
className="size-full"
|
||||
decoding="async"
|
||||
height={40}
|
||||
loading="lazy"
|
||||
src={iconSrc}
|
||||
alt={`plugin-${plugin_unique_identifier}-logo`}
|
||||
width={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginDetail } from '../../types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getStepByStepTourTargetSelector,
|
||||
@@ -22,8 +22,12 @@ const mockSetFilters = vi.fn()
|
||||
const mockSetCurrentPluginID = vi.fn()
|
||||
const mockLoadNextPage = vi.fn()
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
const mockRetainFirstInstalledPluginPageOnUnmount = vi.fn()
|
||||
const mockUseInstalledPluginList = vi.fn()
|
||||
const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => [])
|
||||
const intersectionObserverCallbacks: IntersectionObserverCallback[] = []
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
queryOptions: (options: unknown) => options,
|
||||
@@ -36,6 +40,8 @@ vi.mock('@/i18n-config', () => ({
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: (...args: unknown[]) => mockUseInstalledPluginList(...args),
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
useRetainFirstInstalledPluginPageOnUnmount: (...args: unknown[]) =>
|
||||
mockRetainFirstInstalledPluginPageOnUnmount(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
@@ -265,6 +271,18 @@ describe('PluginsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
intersectionObserverCallbacks.length = 0
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionObserverCallbacks.push(callback)
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
},
|
||||
)
|
||||
mockState.filters = { categories: [], tags: [], searchQuery: '' }
|
||||
mockState.currentPluginID = undefined
|
||||
mockUseInstalledPluginList.mockReturnValue({
|
||||
@@ -279,6 +297,7 @@ describe('PluginsPanel', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the loading state while the plugin list is pending', () => {
|
||||
@@ -351,23 +370,40 @@ describe('PluginsPanel', () => {
|
||||
expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('tool-plugin')
|
||||
})
|
||||
|
||||
it('loads the scoped plugin category list whenever an integrations category panel mounts', () => {
|
||||
render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.trigger} />)
|
||||
it.each([
|
||||
PluginCategoryEnum.tool,
|
||||
PluginCategoryEnum.trigger,
|
||||
PluginCategoryEnum.agent,
|
||||
PluginCategoryEnum.extension,
|
||||
])('loads %s Integration Plugins in Studio-sized pages', (category) => {
|
||||
render(<PluginsPanel contentInset="compact" fixedCategory={category} />)
|
||||
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
|
||||
category: PluginCategoryEnum.trigger,
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith({
|
||||
category,
|
||||
pageSize: 30,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
})
|
||||
|
||||
it('configures first-page cache retention for an Integration category panel', () => {
|
||||
render(<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />)
|
||||
|
||||
expect(mockRetainFirstInstalledPluginPageOnUnmount).toHaveBeenCalledWith(
|
||||
PluginCategoryEnum.tool,
|
||||
30,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not configure first-page cache retention for the standalone Plugin page', () => {
|
||||
render(<PluginsPanel />)
|
||||
|
||||
expect(mockRetainFirstInstalledPluginPageOnUnmount).toHaveBeenCalledWith(undefined, 30)
|
||||
})
|
||||
|
||||
it('loads the scoped tool plugin category list when fixed to tool plugins', () => {
|
||||
render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.tool} />)
|
||||
|
||||
expect(screen.getByTestId('filter-management')).toHaveAttribute('data-hide-tag-filter', 'false')
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
|
||||
category: PluginCategoryEnum.tool,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
})
|
||||
|
||||
it('filters tool plugins, builtin tools, and marketplace suggestions by selected tags', () => {
|
||||
@@ -711,6 +747,56 @@ describe('PluginsPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
PluginCategoryEnum.tool,
|
||||
PluginCategoryEnum.trigger,
|
||||
PluginCategoryEnum.agent,
|
||||
PluginCategoryEnum.extension,
|
||||
])('automatically loads more %s Plugins near the list end once per intersection', (category) => {
|
||||
mockPluginListWithLatestVersion.mockReturnValue([
|
||||
createPlugin('category-plugin', 'Category Plugin', [], category),
|
||||
])
|
||||
mockUseInstalledPluginList.mockReturnValue({
|
||||
data: { plugins: [] },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isLastPage: false,
|
||||
loadNextPage: mockLoadNextPage,
|
||||
})
|
||||
|
||||
render(<PluginsPanel fixedCategory={category} />)
|
||||
|
||||
expect(intersectionObserverCallbacks).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
intersectionObserverCallbacks[0]?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
intersectionObserverCallbacks[0]?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockLoadNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not observe the Tool Plugin list while the next page is loading', () => {
|
||||
mockPluginListWithLatestVersion.mockReturnValue([createPlugin('tool-plugin', 'Tool Plugin')])
|
||||
mockUseInstalledPluginList.mockReturnValue({
|
||||
data: { plugins: [] },
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
isLastPage: false,
|
||||
loadNextPage: mockLoadNextPage,
|
||||
})
|
||||
|
||||
render(<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />)
|
||||
|
||||
expect(intersectionObserverCallbacks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the empty state and keeps the current plugin detail in sync', () => {
|
||||
mockState.currentPluginID = 'beta-tool'
|
||||
mockState.filters.searchQuery = 'missing'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useToolMarketplacePanel } from '@/app/components/integrations/hooks/use-tool-marketplace-panel'
|
||||
@@ -54,6 +55,7 @@ const BuiltinMarketplacePanel = ({
|
||||
}
|
||||
|
||||
type PluginsPanelResultsProps = {
|
||||
autoLoadNextPage: boolean
|
||||
canDeletePlugin: boolean
|
||||
canUpdatePlugin: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
@@ -78,6 +80,7 @@ type PluginsPanelResultsProps = {
|
||||
}
|
||||
|
||||
const PluginsPanelResults = ({
|
||||
autoLoadNextPage,
|
||||
canDeletePlugin,
|
||||
canUpdatePlugin,
|
||||
containerRef,
|
||||
@@ -101,6 +104,42 @@ const PluginsPanelResults = ({
|
||||
tagFilterValue,
|
||||
}: PluginsPanelResultsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const loadMoreAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const loadNextPageRequestedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = loadMoreAnchorRef.current
|
||||
const root = containerRef.current
|
||||
|
||||
if (!isFetching) loadNextPageRequestedRef.current = false
|
||||
|
||||
if (
|
||||
!autoLoadNextPage ||
|
||||
!anchor ||
|
||||
!root ||
|
||||
isFetching ||
|
||||
isLastPage ||
|
||||
!globalThis.IntersectionObserver
|
||||
)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting || loadNextPageRequestedRef.current) return
|
||||
|
||||
loadNextPageRequestedRef.current = true
|
||||
loadNextPage()
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: '200px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(anchor)
|
||||
return () => observer.disconnect()
|
||||
}, [autoLoadNextPage, containerRef, isFetching, isLastPage, loadNextPage])
|
||||
|
||||
return (
|
||||
<ScrollAreaRoot
|
||||
@@ -149,11 +188,14 @@ const PluginsPanelResults = ({
|
||||
<div className="flex w-full justify-center py-4">
|
||||
{isFetching ? (
|
||||
<Loading className="size-8" />
|
||||
) : (
|
||||
) : autoLoadNextPage ? null : (
|
||||
<Button onClick={loadNextPage}>
|
||||
{t(($) => $['common.loadMore'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
{autoLoadNextPage && (
|
||||
<div ref={loadMoreAnchorRef} className="h-px w-full" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasToolMarketplacePanel && (
|
||||
|
||||
@@ -15,7 +15,12 @@ import ProviderDetail from '@/app/components/tools/provider/detail'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import {
|
||||
normalizePluginCategoryListLanguage,
|
||||
useInstalledPluginList,
|
||||
useInvalidateInstalledPluginList,
|
||||
useRetainFirstInstalledPluginPageOnUnmount,
|
||||
} from '@/service/use-plugins'
|
||||
import { usePluginsWithLatestVersion } from '../hooks'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { pluginPageContentFrameClassNames, pluginPageContentInsetClassNames } from './content-inset'
|
||||
@@ -26,6 +31,8 @@ import PluginListSkeleton from './plugin-list-skeleton'
|
||||
import PluginsPanelResults from './plugins-panel-results'
|
||||
import { EMPTY_BUILTIN_TOOLS, filterBuiltinTools } from './plugins-panel-utils'
|
||||
|
||||
const INTEGRATION_PLUGIN_PAGE_SIZE = 30
|
||||
|
||||
const matchesSearchQuery = (
|
||||
plugin: PluginDetail & { latest_version: string },
|
||||
query: string,
|
||||
@@ -84,6 +91,17 @@ const PluginsPanel = ({
|
||||
isAgentStrategyIntegrationPage ||
|
||||
isExtensionIntegrationPage
|
||||
const supportsTagFilter = !fixedCategory || isToolIntegrationPage || isTriggerIntegrationPage
|
||||
const installedPluginFilters = useMemo(
|
||||
() =>
|
||||
isIntegrationCategoryPage
|
||||
? {
|
||||
language: normalizePluginCategoryListLanguage(locale),
|
||||
query: filters.searchQuery,
|
||||
tags: supportsTagFilter ? filters.tags : [],
|
||||
}
|
||||
: undefined,
|
||||
[filters.searchQuery, filters.tags, isIntegrationCategoryPage, locale, supportsTagFilter],
|
||||
)
|
||||
const { data: enableMarketplace } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: (s) => s.enable_marketplace,
|
||||
@@ -94,18 +112,19 @@ const PluginsPanel = ({
|
||||
isFetching,
|
||||
isLastPage,
|
||||
loadNextPage,
|
||||
} = useInstalledPluginList(
|
||||
false,
|
||||
100,
|
||||
fixedCategory
|
||||
? {
|
||||
category: fixedCategory,
|
||||
refetchOnMount: isIntegrationCategoryPage ? 'always' : undefined,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
} = useInstalledPluginList({
|
||||
category: fixedCategory,
|
||||
filters: installedPluginFilters,
|
||||
pageSize: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_PAGE_SIZE : 100,
|
||||
refetchOnMount: isIntegrationCategoryPage ? 'always' : undefined,
|
||||
})
|
||||
const pluginListWithLatestVersion = usePluginsWithLatestVersion(pluginList?.plugins)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
useRetainFirstInstalledPluginPageOnUnmount(
|
||||
isIntegrationCategoryPage ? fixedCategory : undefined,
|
||||
INTEGRATION_PLUGIN_PAGE_SIZE,
|
||||
installedPluginFilters,
|
||||
)
|
||||
const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
|
||||
const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
|
||||
const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState<string | undefined>()
|
||||
@@ -236,6 +255,7 @@ const PluginsPanel = ({
|
||||
<>
|
||||
{hasVisiblePlugins || hasVisibleBuiltinTools || hasToolMarketplacePanel ? (
|
||||
<PluginsPanelResults
|
||||
autoLoadNextPage={isIntegrationCategoryPage}
|
||||
containerRef={containerRef}
|
||||
contentFrameClassName={contentFrameClassName}
|
||||
contentInset={contentInset}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PluginVersionPicker from '../plugin-version-picker'
|
||||
|
||||
@@ -14,6 +15,8 @@ const mockVersionList = vi.hoisted(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUseVersionListOfPlugin = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatDate: (value: string, format: string) => `${value}:${format}`,
|
||||
@@ -21,14 +24,19 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useVersionListOfPlugin: () => ({
|
||||
useVersionListOfPlugin: mockUseVersionListOfPlugin.mockImplementation(() => ({
|
||||
data: mockVersionList,
|
||||
}),
|
||||
isLoading: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('PluginVersionPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseVersionListOfPlugin.mockReturnValue({
|
||||
data: mockVersionList,
|
||||
isLoading: false,
|
||||
})
|
||||
mockVersionList.data.versions = [
|
||||
{
|
||||
version: '2.0.0',
|
||||
@@ -43,6 +51,55 @@ describe('PluginVersionPicker', () => {
|
||||
]
|
||||
})
|
||||
|
||||
it('loads versions only while the popover is open', () => {
|
||||
const { rerender } = render(
|
||||
<PluginVersionPicker
|
||||
isShow={false}
|
||||
onShowChange={vi.fn()}
|
||||
pluginID="plugin-1"
|
||||
currentVersion="2.0.0"
|
||||
trigger={<span>trigger</span>}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseVersionListOfPlugin).toHaveBeenLastCalledWith('plugin-1', false)
|
||||
|
||||
rerender(
|
||||
<PluginVersionPicker
|
||||
isShow
|
||||
onShowChange={vi.fn()}
|
||||
pluginID="plugin-1"
|
||||
currentVersion="2.0.0"
|
||||
trigger={<span>trigger</span>}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseVersionListOfPlugin).toHaveBeenLastCalledWith('plugin-1', true)
|
||||
})
|
||||
|
||||
it('shows a loading state while versions are loading', () => {
|
||||
mockUseVersionListOfPlugin.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginVersionPicker
|
||||
isShow
|
||||
onShowChange={vi.fn()}
|
||||
pluginID="plugin-1"
|
||||
currentVersion="2.0.0"
|
||||
trigger={<span>trigger</span>}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('2.0.0')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders version options and highlights the current version', () => {
|
||||
render(
|
||||
<PluginVersionPicker
|
||||
@@ -88,9 +145,10 @@ describe('PluginVersionPicker', () => {
|
||||
expect(currentBadge).toHaveClass('bg-components-badge-bg-dimm')
|
||||
})
|
||||
|
||||
it('calls onSelect with downgrade metadata and closes the picker', () => {
|
||||
it('calls onSelect with downgrade metadata and closes the picker', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const onShowChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PluginVersionPicker
|
||||
@@ -103,7 +161,7 @@ describe('PluginVersionPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('1.0.0'))
|
||||
await user.click(screen.getByText('1.0.0'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
version: '1.0.0',
|
||||
@@ -113,8 +171,9 @@ describe('PluginVersionPicker', () => {
|
||||
expect(onShowChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not call onSelect when the current version is clicked', () => {
|
||||
it('does not call onSelect when the current version is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PluginVersionPicker
|
||||
@@ -127,7 +186,7 @@ describe('PluginVersionPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('2.0.0'))
|
||||
await user.click(screen.getByText('2.0.0'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
const format = t(($) => $.dateTimeFormat, { ns: 'appLog' }).split(' ')[0]
|
||||
const { formatDate } = useTimestamp()
|
||||
|
||||
const { data: res } = useVersionListOfPlugin(pluginID)
|
||||
const { data: res, isLoading } = useVersionListOfPlugin(pluginID, isShow && !disabled)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
({
|
||||
@@ -91,35 +91,49 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
{t(($) => $['detailPanel.switchVersion'], { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className="relative max-h-[224px] overflow-y-auto">
|
||||
{res?.data.versions.map((version) => (
|
||||
{isLoading ? (
|
||||
<div
|
||||
key={version.unique_identifier}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-lg px-2 py-1 hover:bg-state-base-hover',
|
||||
currentVersion === version.version &&
|
||||
'cursor-default opacity-30 hover:bg-transparent',
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelect({
|
||||
version: version.version,
|
||||
unique_identifier: version.unique_identifier,
|
||||
isDowngrade: isEarlierThanVersion(version.version, currentVersion),
|
||||
})
|
||||
}
|
||||
role="status"
|
||||
aria-label={t(($) => $.loading, { ns: 'common' })}
|
||||
className="flex h-12 items-center justify-center"
|
||||
>
|
||||
<div className="flex min-h-5 min-w-0 grow items-center gap-1 px-1">
|
||||
<div className="min-w-0 grow truncate system-sm-medium text-text-secondary">
|
||||
{version.version}
|
||||
</div>
|
||||
{currentVersion === version.version && (
|
||||
<Badge className="shrink-0" variant="dimm" text="CURRENT" />
|
||||
)}
|
||||
<div className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{formatDate(version.created_at, format!)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
res?.data.versions.map((version) => (
|
||||
<button
|
||||
key={version.unique_identifier}
|
||||
type="button"
|
||||
disabled={currentVersion === version.version}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center rounded-lg border-0 px-2 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-default disabled:hover:bg-transparent',
|
||||
currentVersion === version.version && 'cursor-default opacity-30',
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelect({
|
||||
version: version.version,
|
||||
unique_identifier: version.unique_identifier,
|
||||
isDowngrade: isEarlierThanVersion(version.version, currentVersion),
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-5 min-w-0 grow items-center gap-1 px-1">
|
||||
<div className="min-w-0 grow truncate system-sm-medium text-text-secondary">
|
||||
{version.version}
|
||||
</div>
|
||||
{currentVersion === version.version && (
|
||||
<Badge className="shrink-0" variant="dimm" text="CURRENT" />
|
||||
)}
|
||||
<div className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{formatDate(version.created_at, format!)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useMarketplace } from '../hooks'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
@@ -18,15 +19,27 @@ const mockFetchNextPage = vi.fn()
|
||||
|
||||
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
|
||||
const mockUseMarketplacePlugins = vi.fn()
|
||||
const mockInstalledIdsQueryOptions = vi.fn()
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) =>
|
||||
mockUseMarketplaceCollectionsAndPlugins(...args),
|
||||
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
|
||||
}))
|
||||
|
||||
const mockUseAllToolProviders = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
plugin: {
|
||||
installedIds: {
|
||||
get: {
|
||||
queryOptions: (...args: unknown[]) => mockInstalledIdsQueryOptions(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
@@ -35,20 +48,12 @@ vi.mock('@/utils/var', () => ({
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
author: 'Author',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
label: { en_US: 'label', zh_Hans: '标签' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
}
|
||||
|
||||
const setupHookMocks = (overrides?: {
|
||||
isLoading?: boolean
|
||||
@@ -80,27 +85,24 @@ const setupHookMocks = (overrides?: {
|
||||
describe('useMarketplace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [],
|
||||
isSuccess: true,
|
||||
mockInstalledIdsQueryOptions.mockReturnValue({
|
||||
queryKey: ['installed-plugin-ids', 'tool'],
|
||||
queryFn: () => Promise.resolve({ plugin_ids: [] }),
|
||||
})
|
||||
setupHookMocks()
|
||||
})
|
||||
|
||||
describe('Queries', () => {
|
||||
it('should query plugins with debounce when search text is provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [
|
||||
createToolProvider({ plugin_id: 'plugin-a' }),
|
||||
createToolProvider({ plugin_id: undefined }),
|
||||
],
|
||||
isSuccess: true,
|
||||
it('should query plugins when the debounced page filter provides search text', async () => {
|
||||
mockInstalledIdsQueryOptions.mockReturnValue({
|
||||
queryKey: ['installed-plugin-ids', 'tool'],
|
||||
queryFn: () => Promise.resolve({ plugin_ids: ['plugin-a'] }),
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('alpha', []))
|
||||
renderHook(() => useMarketplace('alpha', []), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
|
||||
expect(mockQueryPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: 'alpha',
|
||||
tags: [],
|
||||
@@ -108,17 +110,18 @@ describe('useMarketplace', () => {
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
expect(mockQueryPluginsWithDebounced).not.toHaveBeenCalled()
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
|
||||
expect(mockResetPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should query plugins immediately when only tags are provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-b' })],
|
||||
isSuccess: true,
|
||||
mockInstalledIdsQueryOptions.mockReturnValue({
|
||||
queryKey: ['installed-plugin-ids', 'tool'],
|
||||
queryFn: () => Promise.resolve({ plugin_ids: ['plugin-b'] }),
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('', ['tag-1']))
|
||||
renderHook(() => useMarketplace('', ['tag-1']), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPlugins).toHaveBeenCalledWith({
|
||||
@@ -132,12 +135,12 @@ describe('useMarketplace', () => {
|
||||
})
|
||||
|
||||
it('should query collections and reset plugins when no filters are provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-c' })],
|
||||
isSuccess: true,
|
||||
mockInstalledIdsQueryOptions.mockReturnValue({
|
||||
queryKey: ['installed-plugin-ids', 'tool'],
|
||||
queryFn: () => Promise.resolve({ plugin_ids: ['plugin-c'] }),
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('', []))
|
||||
renderHook(() => useMarketplace('', []), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
|
||||
@@ -155,7 +158,7 @@ describe('useMarketplace', () => {
|
||||
it('should expose combined loading state and fallback page value', () => {
|
||||
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
|
||||
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
const { result } = renderHook(() => useMarketplace('', []), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.page).toBe(1)
|
||||
@@ -165,7 +168,9 @@ describe('useMarketplace', () => {
|
||||
describe('Scroll', () => {
|
||||
it('should fetch next page when scrolling near bottom with filters', () => {
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('search', []))
|
||||
const { result } = renderHook(() => useMarketplace('search', []), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
@@ -183,7 +188,7 @@ describe('useMarketplace', () => {
|
||||
|
||||
it('should not fetch next page when no filters are applied', () => {
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
const { result } = renderHook(() => useMarketplace('', []), { wrapper: createWrapper() })
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
@@ -6,16 +7,15 @@ import {
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => {
|
||||
const { data: toolProvidersData, isSuccess } = useAllToolProviders()
|
||||
const exclude = useMemo(() => {
|
||||
if (isSuccess)
|
||||
return toolProvidersData
|
||||
?.filter((toolProvider) => !!toolProvider.plugin_id)
|
||||
.map((toolProvider) => toolProvider.plugin_id!)
|
||||
}, [isSuccess, toolProvidersData])
|
||||
const { data: installedPluginIds, isSuccess } = useQuery(
|
||||
consoleQuery.workspaces.current.plugin.installedIds.get.queryOptions({
|
||||
input: { query: { category: 'tool' } },
|
||||
}),
|
||||
)
|
||||
const exclude = installedPluginIds?.plugin_ids
|
||||
const {
|
||||
isLoading,
|
||||
marketplaceCollections,
|
||||
@@ -26,7 +26,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
plugins,
|
||||
resetPlugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading: isPluginsLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -42,7 +41,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
useEffect(() => {
|
||||
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
|
||||
if (searchPluginText) {
|
||||
queryPluginsWithDebounced({
|
||||
queryPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
tags: filterPluginTags,
|
||||
@@ -74,7 +73,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
filterPluginTags,
|
||||
queryPlugins,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
resetPlugins,
|
||||
exclude,
|
||||
isSuccess,
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import MCPList from '../index'
|
||||
|
||||
type MockProvider = {
|
||||
id: string
|
||||
name: string | Record<string, string>
|
||||
type: string
|
||||
}
|
||||
|
||||
type MockDetail = MockProvider | undefined
|
||||
import MCPListComponent from '../index'
|
||||
|
||||
// Mock dependencies
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUseAllToolProviders = vi.fn()
|
||||
let mockProviders: MockProvider[] = []
|
||||
const mockOnRefresh = vi.fn<() => Promise<void>>()
|
||||
let mockProviders: ToolWithProvider[] = []
|
||||
let mockIsLoadingToolProviders = false
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: ['mcp.manage'] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: (enabled?: boolean) => {
|
||||
mockUseAllToolProviders(enabled)
|
||||
return {
|
||||
data: mockProviders,
|
||||
isLoading: mockIsLoadingToolProviders,
|
||||
refetch: mockRefetch,
|
||||
}
|
||||
},
|
||||
}))
|
||||
const createProvider = (
|
||||
id: string,
|
||||
name: string,
|
||||
type: ToolWithProvider['type'] = 'mcp',
|
||||
): ToolWithProvider => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
author: 'Dify',
|
||||
description: { en_US: name, zh_Hans: name },
|
||||
icon: 'icon-mcp',
|
||||
label: { en_US: name, zh_Hans: name },
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
tools: [],
|
||||
meta: { version: '1.0.0' },
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({}))
|
||||
|
||||
type MCPListTestProps = Omit<
|
||||
ComponentProps<typeof MCPListComponent>,
|
||||
'isLoading' | 'onRefresh' | 'providers'
|
||||
>
|
||||
|
||||
const MCPList = (props: MCPListTestProps) => (
|
||||
<MCPListComponent
|
||||
{...props}
|
||||
providers={mockProviders}
|
||||
isLoading={mockIsLoadingToolProviders}
|
||||
onRefresh={mockOnRefresh}
|
||||
/>
|
||||
)
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
@@ -75,16 +93,15 @@ vi.mock('../provider-card', () => ({
|
||||
onUpdate,
|
||||
onDeleted,
|
||||
}: {
|
||||
data: MockProvider
|
||||
data: ToolWithProvider
|
||||
handleSelect: (id: string) => void
|
||||
onUpdate: (id: string) => void
|
||||
onDeleted: () => void
|
||||
}) => {
|
||||
const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0]
|
||||
return (
|
||||
<div data-testid={`provider-card-${data.id}`}>
|
||||
<button type="button" onClick={() => handleSelect(data.id)}>
|
||||
{displayName}
|
||||
{data.name}
|
||||
</button>
|
||||
<button data-testid={`update-btn-${data.id}`} onClick={() => onUpdate(data.id)}>
|
||||
Update
|
||||
@@ -105,17 +122,13 @@ vi.mock('../detail/provider-detail', () => ({
|
||||
isTriggerAuthorize,
|
||||
onFirstCreate,
|
||||
}: {
|
||||
detail: MockDetail
|
||||
detail: ToolWithProvider | undefined
|
||||
onHide: () => void
|
||||
onUpdate: () => void
|
||||
isTriggerAuthorize: boolean
|
||||
onFirstCreate: () => void
|
||||
}) => {
|
||||
const displayName = detail?.name
|
||||
? typeof detail.name === 'string'
|
||||
? detail.name
|
||||
: Object.values(detail.name)[0]
|
||||
: ''
|
||||
const displayName = detail?.name ?? ''
|
||||
return (
|
||||
<div data-testid="detail-panel">
|
||||
<div data-testid="detail-name">{displayName}</div>
|
||||
@@ -141,7 +154,7 @@ describe('MCPList', () => {
|
||||
mockProviders = []
|
||||
mockIsLoadingToolProviders = false
|
||||
mockConsoleState.workspacePermissionKeys = ['mcp.manage']
|
||||
mockRefetch.mockResolvedValue(undefined)
|
||||
mockOnRefresh.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,17 +170,16 @@ describe('MCPList', () => {
|
||||
|
||||
it('should render providers read-only when user lacks mcp.manage', () => {
|
||||
mockConsoleState.workspacePermissionKeys = []
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(mockUseAllToolProviders).toHaveBeenCalledWith(undefined)
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create card when parent moves creation into the toolbar', () => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
|
||||
render(<MCPList searchText="" showCreateCard={false} />)
|
||||
|
||||
@@ -192,7 +204,7 @@ describe('MCPList', () => {
|
||||
})
|
||||
|
||||
it('should not render skeleton cards when providers exist', () => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.queryByTestId('mcp-card-skeleton')).not.toBeInTheDocument()
|
||||
@@ -202,9 +214,9 @@ describe('MCPList', () => {
|
||||
describe('With Providers', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
{ id: '2', name: 'Provider 2', type: 'mcp' },
|
||||
{ id: '3', name: 'API Tool', type: 'api' },
|
||||
createProvider('1', 'Provider 1'),
|
||||
createProvider('2', 'Provider 2'),
|
||||
createProvider('3', 'API Tool', 'api'),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -257,9 +269,9 @@ describe('MCPList', () => {
|
||||
describe('Search Filtering', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: { 'en-US': 'Search Tool' }, type: 'mcp' },
|
||||
{ id: '2', name: { 'en-US': 'Another Provider' }, type: 'mcp' },
|
||||
{ id: '3', name: { 'en-US': 'Search API Tool' }, type: 'api' },
|
||||
createProvider('1', 'Search Tool'),
|
||||
createProvider('2', 'Another Provider'),
|
||||
createProvider('3', 'Search API Tool', 'api'),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -278,10 +290,7 @@ describe('MCPList', () => {
|
||||
})
|
||||
|
||||
it('should show all MCP type providers when search is empty', () => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
{ id: '2', name: 'Provider 2', type: 'mcp' },
|
||||
]
|
||||
mockProviders = [createProvider('1', 'Provider 1'), createProvider('2', 'Provider 2')]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
@@ -305,11 +314,11 @@ describe('MCPList', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel with trigger authorize after create', async () => {
|
||||
mockProviders = [{ id: 'new-id', name: 'New Provider', type: 'mcp' }]
|
||||
mockProviders = [createProvider('new-id', 'New Provider')]
|
||||
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
@@ -326,7 +335,7 @@ describe('MCPList', () => {
|
||||
})
|
||||
|
||||
it('should reset trigger authorize when onFirstCreate is called', async () => {
|
||||
mockProviders = [{ id: 'new-id', name: 'New Provider', type: 'mcp' }]
|
||||
mockProviders = [createProvider('new-id', 'New Provider')]
|
||||
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
@@ -351,7 +360,7 @@ describe('MCPList', () => {
|
||||
})
|
||||
|
||||
it('should refetch and open detail when provider is created from the toolbar', async () => {
|
||||
mockProviders = [{ id: 'toolbar-id', name: 'Toolbar Provider', type: 'mcp' }]
|
||||
mockProviders = [createProvider('toolbar-id', 'Toolbar Provider')]
|
||||
const onCreatedProviderHandled = vi.fn()
|
||||
|
||||
await act(async () => {
|
||||
@@ -366,7 +375,7 @@ describe('MCPList', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockOnRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('Toolbar Provider')
|
||||
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('true')
|
||||
@@ -376,7 +385,7 @@ describe('MCPList', () => {
|
||||
|
||||
describe('Update Provider', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
})
|
||||
|
||||
it('should call refetch and set provider after update', async () => {
|
||||
@@ -390,7 +399,7 @@ describe('MCPList', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel with trigger authorize after update', async () => {
|
||||
@@ -411,7 +420,7 @@ describe('MCPList', () => {
|
||||
|
||||
describe('Delete Provider', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
})
|
||||
|
||||
it('should call refetch after delete', async () => {
|
||||
@@ -424,7 +433,7 @@ describe('MCPList', () => {
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -449,7 +458,7 @@ describe('MCPList', () => {
|
||||
})
|
||||
|
||||
it('should not have overflow hidden when loading is complete', () => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
mockProviders = [createProvider('1', 'Provider 1')]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const grid = document.querySelector('.grid')
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions'
|
||||
import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset'
|
||||
import NewMCPCard from './create-card'
|
||||
import MCPDetailPanel from './detail/provider-detail'
|
||||
@@ -16,7 +15,10 @@ type Props = Readonly<{
|
||||
searchText: string
|
||||
contentInset?: ToolsContentInset
|
||||
createdProviderId?: string
|
||||
isLoading: boolean
|
||||
onCreatedProviderHandled?: () => void
|
||||
onRefresh: () => Promise<void>
|
||||
providers: ToolWithProvider[]
|
||||
showCreateCard?: boolean
|
||||
}>
|
||||
|
||||
@@ -24,34 +26,33 @@ const MCPList = ({
|
||||
searchText,
|
||||
contentInset = 'default',
|
||||
createdProviderId,
|
||||
isLoading,
|
||||
onCreatedProviderHandled,
|
||||
onRefresh,
|
||||
providers,
|
||||
showCreateCard = true,
|
||||
}: Props) => {
|
||||
const canManageMCP = useCanManageMCP()
|
||||
const { data: list = [] as ToolWithProvider[], isLoading, refetch } = useAllToolProviders()
|
||||
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
return list.filter((collection) => {
|
||||
return providers.filter((collection) => {
|
||||
if (collection.type !== 'mcp') return false
|
||||
if (searchText)
|
||||
return Object.values(collection.name).some((value) =>
|
||||
(value as string).toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
if (searchText) return collection.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
return true
|
||||
}) as ToolWithProvider[]
|
||||
}, [list, searchText])
|
||||
}, [providers, searchText])
|
||||
|
||||
const [currentProviderID, setCurrentProviderID] = useState<string>()
|
||||
|
||||
const currentProvider = useMemo(() => {
|
||||
return list.find((provider) => provider.id === currentProviderID)
|
||||
}, [list, currentProviderID])
|
||||
return providers.find((provider) => provider.id === currentProviderID)
|
||||
}, [providers, currentProviderID])
|
||||
|
||||
const handleCreate = async (provider: ToolWithProvider) => {
|
||||
if (!canManageMCP) return
|
||||
|
||||
await refetch() // update list
|
||||
await onRefresh() // update list
|
||||
setCurrentProviderID(provider.id)
|
||||
setIsTriggerAuthorize(true)
|
||||
}
|
||||
@@ -63,7 +64,7 @@ const MCPList = ({
|
||||
|
||||
const openCreatedProvider = async () => {
|
||||
try {
|
||||
await refetch()
|
||||
await onRefresh()
|
||||
if (!isActive) return
|
||||
|
||||
setCurrentProviderID(createdProviderId)
|
||||
@@ -78,12 +79,12 @@ const MCPList = ({
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [canManageMCP, createdProviderId, onCreatedProviderHandled, refetch])
|
||||
}, [canManageMCP, createdProviderId, onCreatedProviderHandled, onRefresh])
|
||||
|
||||
const handleUpdate = async (providerID: string) => {
|
||||
if (!canManageMCP) return
|
||||
|
||||
await refetch() // update list
|
||||
await onRefresh() // update list
|
||||
setCurrentProviderID(providerID)
|
||||
setIsTriggerAuthorize(true)
|
||||
}
|
||||
@@ -114,7 +115,7 @@ const MCPList = ({
|
||||
currentProvider={currentProvider as ToolWithProvider}
|
||||
handleSelect={setCurrentProviderID}
|
||||
onUpdate={handleUpdate}
|
||||
onDeleted={refetch}
|
||||
onDeleted={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -124,7 +125,7 @@ const MCPList = ({
|
||||
<MCPDetailPanel
|
||||
detail={currentProvider as ToolWithProvider}
|
||||
onHide={() => setCurrentProviderID(undefined)}
|
||||
onUpdate={refetch}
|
||||
onUpdate={onRefresh}
|
||||
isTriggerAuthorize={isTriggerAuthorize}
|
||||
onFirstCreate={() => setIsTriggerAuthorize(false)}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,11 @@ const resolveMemberInviteLimit = (
|
||||
export const ProviderContextProvider = ({ children }: ProviderContextProviderProps) => {
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: providersData, isLoading: isLoadingModelProviders } = useModelProviders()
|
||||
const {
|
||||
data: providersData,
|
||||
isLoading: isLoadingModelProviders,
|
||||
isSuccess: isSuccessModelProviders,
|
||||
} = useModelProviders()
|
||||
const { data: textGenerationModelList } = useModelListByType(ModelTypeEnum.textGeneration)
|
||||
const { data: supportRetrievalMethods } = useSupportRetrievalMethods()
|
||||
|
||||
@@ -200,6 +204,7 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
value={{
|
||||
modelProviders: providersData?.data || [],
|
||||
isLoadingModelProviders,
|
||||
isSuccessModelProviders,
|
||||
refreshModelProviders,
|
||||
textGenerationModelList: textGenerationModelList?.data || [],
|
||||
isAPIKeySet: !!textGenerationModelList?.data?.some(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { defaultPlan } from '@/app/components/billing/config'
|
||||
export type ProviderContextState = {
|
||||
modelProviders: ModelProvider[]
|
||||
isLoadingModelProviders: boolean
|
||||
isSuccessModelProviders: boolean
|
||||
refreshModelProviders: () => void
|
||||
textGenerationModelList: Model[]
|
||||
supportRetrievalMethods: RETRIEVE_METHOD[]
|
||||
@@ -53,6 +54,7 @@ export type ProviderContextState = {
|
||||
export const baseProviderContextValue: ProviderContextState = {
|
||||
modelProviders: [],
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
refreshModelProviders: noop,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
PluginCategoryInstalledPluginResponse,
|
||||
PluginEntity,
|
||||
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'
|
||||
@@ -23,20 +27,32 @@ import {
|
||||
useMutationPluginPermissionSettings,
|
||||
usePluginAutoUpgradeSettings,
|
||||
usePluginTaskList,
|
||||
useRetainFirstInstalledPluginPageOnUnmount,
|
||||
useVersionListOfPlugin,
|
||||
} from '../use-plugins'
|
||||
|
||||
const { mockGet, mockPost } = vi.hoisted(() => ({
|
||||
const { mockGet, mockGetMarketplace, mockPost, mockRequest } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockGetMarketplace: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRequest: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: mockGet,
|
||||
getMarketplace: vi.fn(),
|
||||
getMarketplace: mockGetMarketplace,
|
||||
post: mockPost,
|
||||
postMarketplace: vi.fn(),
|
||||
request: mockRequest,
|
||||
}))
|
||||
|
||||
mockRequest.mockImplementation(async (url: string) => {
|
||||
const data = await mockGet(url)
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: vi.fn(),
|
||||
@@ -220,7 +236,66 @@ const createPluginInstallation = (): PluginInstallationItemResponse => ({
|
||||
},
|
||||
})
|
||||
|
||||
const createInstalledPlugin = (
|
||||
pluginId: string,
|
||||
category: PluginCategoryEnum,
|
||||
): PluginCategoryInstalledPluginResponse => {
|
||||
const installation = createPluginInstallation()
|
||||
|
||||
return {
|
||||
...installation,
|
||||
installation_id: installation.id,
|
||||
name: installation.declaration.name,
|
||||
plugin_id: pluginId,
|
||||
declaration: {
|
||||
...installation.declaration,
|
||||
category,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createPluginEntity = (): PluginEntity => ({
|
||||
checksum: 'checksum',
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
declaration: {
|
||||
author: 'Dify',
|
||||
category: PluginCategoryEnum.tool,
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
description: { en_US: 'Plugin description' },
|
||||
icon: 'icon.svg',
|
||||
label: { en_US: 'Plugin label' },
|
||||
meta: { version: '1.0.0' },
|
||||
name: 'plugin-entity',
|
||||
plugins: {},
|
||||
resource: { memory: 0 },
|
||||
version: '1.0.0',
|
||||
},
|
||||
endpoints_active: 1,
|
||||
endpoints_setups: 1,
|
||||
id: 'plugin-entity-id',
|
||||
installation_id: 'plugin-entity-id',
|
||||
meta: {},
|
||||
name: 'plugin-entity',
|
||||
plugin_id: 'langgenius/plugin-entity',
|
||||
plugin_unique_identifier: 'langgenius/plugin-entity:1.0.0@test',
|
||||
runtime_type: 'local',
|
||||
source: PluginSource.marketplace,
|
||||
tenant_id: 'tenant-id',
|
||||
updated_at: '2026-06-02T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
describe('normalizeInstalledPluginDetail', () => {
|
||||
it('adapts generic and category list items to the legacy detail model', () => {
|
||||
const genericDetail = normalizeInstalledPluginDetail(createPluginEntity())
|
||||
const categoryDetail = normalizeInstalledPluginDetail(
|
||||
createInstalledPlugin('langgenius/category-plugin', PluginCategoryEnum.tool),
|
||||
)
|
||||
|
||||
expect(genericDetail.plugin_id).toBe('langgenius/plugin-entity')
|
||||
expect(categoryDetail.plugin_id).toBe('langgenius/category-plugin')
|
||||
})
|
||||
|
||||
it('should preserve generated plugin declaration capabilities', () => {
|
||||
const detail = normalizeInstalledPluginDetail(createPluginInstallation())
|
||||
|
||||
@@ -469,6 +544,37 @@ describe('use-plugins mutations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useVersionListOfPlugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not fetch versions while disabled', () => {
|
||||
const queryClient = createQueryClient()
|
||||
|
||||
renderHook(() => useVersionListOfPlugin('plugin-1', false), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockGetMarketplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches versions when enabled', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGetMarketplace.mockResolvedValue({ data: { versions: [] } })
|
||||
|
||||
renderHook(() => useVersionListOfPlugin('plugin-1'), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetMarketplace).toHaveBeenCalledWith('/plugins/plugin-1/versions', {
|
||||
params: { page: 1, page_size: 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInstalledPluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -478,24 +584,128 @@ describe('useInstalledPluginList', () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet.mockResolvedValue({ plugins: [], total: 0 })
|
||||
|
||||
renderHook(() => useInstalledPluginList(false, 100), { wrapper: createWrapper(queryClient) })
|
||||
renderHook(() => useInstalledPluginList(), { wrapper: createWrapper(queryClient) })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/workspaces/current/plugin/list?page=1&page_size=100')
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/list?page=1&page_size=100',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch or expose data when disabled', () => {
|
||||
const queryClient = createQueryClient()
|
||||
|
||||
const { result } = renderHook(() => useInstalledPluginList({ enabled: false }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalled()
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fetches the scoped installed plugin category list when category is provided', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet.mockResolvedValue({ plugins: [], has_more: false })
|
||||
|
||||
renderHook(() => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.trigger }), {
|
||||
renderHook(() => useInstalledPluginList({ category: PluginCategoryEnum.trigger }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/workspaces/current/plugin/trigger/list?page=1&page_size=100',
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/trigger/list?page=1&page_size=100',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('filters every installed tool page on the server', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('tool-plugin-1', PluginCategoryEnum.tool)],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('tool-plugin-2', PluginCategoryEnum.tool)],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useInstalledPluginList({
|
||||
category: PluginCategoryEnum.tool,
|
||||
filters: {
|
||||
language: 'zh_Hans',
|
||||
query: 'slack',
|
||||
tags: ['communication', 'api'],
|
||||
},
|
||||
pageSize: 30,
|
||||
}),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=1&page_size=30&query=slack&language=zh_Hans&tags=communication&tags=api',
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.loadNextPage()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=2&page_size=30&query=slack&language=zh_Hans&tags=communication&tags=api',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps category lists with different page sizes in separate caches', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet.mockResolvedValue({ plugins: [], has_more: false })
|
||||
const wrapper = createWrapper(queryClient)
|
||||
|
||||
renderHook(() => useInstalledPluginList({ category: PluginCategoryEnum.tool, pageSize: 30 }), {
|
||||
wrapper,
|
||||
})
|
||||
renderHook(() => useInstalledPluginList({ category: PluginCategoryEnum.tool }), {
|
||||
wrapper,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=1&page_size=30',
|
||||
)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=1&page_size=100',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps category lists with different filters in separate caches', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet.mockResolvedValue({ plugins: [], has_more: false })
|
||||
const wrapper = createWrapper(queryClient)
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useInstalledPluginList({ category: PluginCategoryEnum.tool, filters: { query: 'slack' } }),
|
||||
{ wrapper },
|
||||
)
|
||||
renderHook(
|
||||
() =>
|
||||
useInstalledPluginList({ category: PluginCategoryEnum.tool, filters: { query: 'notion' } }),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=1&page_size=100&query=slack',
|
||||
)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/tool/list?page=1&page_size=100&query=notion',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -520,7 +730,7 @@ describe('useInstalledPluginList', () => {
|
||||
mockGet.mockResolvedValue({ plugins: [], builtin_tools: builtinTools, has_more: false })
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.tool }),
|
||||
() => useInstalledPluginList({ category: PluginCategoryEnum.tool }),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
@@ -533,16 +743,16 @@ describe('useInstalledPluginList', () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [{ plugin_id: 'trigger-plugin-1' }],
|
||||
plugins: [createInstalledPlugin('trigger-plugin-1', PluginCategoryEnum.trigger)],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [{ plugin_id: 'trigger-plugin-2' }],
|
||||
plugins: [createInstalledPlugin('trigger-plugin-2', PluginCategoryEnum.trigger)],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.trigger }),
|
||||
() => useInstalledPluginList({ category: PluginCategoryEnum.trigger }),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
@@ -556,7 +766,7 @@ describe('useInstalledPluginList', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/workspaces/current/plugin/trigger/list?page=2&page_size=100',
|
||||
'http://localhost:5001/console/api/workspaces/current/plugin/trigger/list?page=2&page_size=100',
|
||||
)
|
||||
})
|
||||
await waitFor(() => {
|
||||
@@ -583,18 +793,18 @@ describe('useInstalledPluginList', () => {
|
||||
]
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [{ plugin_id: 'tool-plugin-1' }],
|
||||
plugins: [createInstalledPlugin('tool-plugin-1', PluginCategoryEnum.tool)],
|
||||
builtin_tools: builtinTools,
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [{ plugin_id: 'tool-plugin-2' }],
|
||||
plugins: [createInstalledPlugin('tool-plugin-2', PluginCategoryEnum.tool)],
|
||||
builtin_tools: builtinTools,
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInstalledPluginList(false, 100, { category: PluginCategoryEnum.tool }),
|
||||
() => useInstalledPluginList({ category: PluginCategoryEnum.tool }),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
@@ -607,15 +817,93 @@ describe('useInstalledPluginList', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.plugins).toEqual([
|
||||
{ plugin_id: 'tool-plugin-1' },
|
||||
{ plugin_id: 'tool-plugin-2' },
|
||||
expect(result.current.data?.plugins.map((plugin) => plugin.plugin_id)).toEqual([
|
||||
'tool-plugin-1',
|
||||
'tool-plugin-2',
|
||||
])
|
||||
})
|
||||
expect(result.current.data?.builtin_tools).toEqual(builtinTools)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRetainFirstInstalledPluginPageOnUnmount', () => {
|
||||
it('retains only the first cached category page for the matching page size', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('first-page-plugin', PluginCategoryEnum.tool)],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('second-page-plugin', PluginCategoryEnum.tool)],
|
||||
has_more: false,
|
||||
})
|
||||
const wrapper = createWrapper(queryClient)
|
||||
const { result } = renderHook(
|
||||
() => useInstalledPluginList({ category: PluginCategoryEnum.tool, pageSize: 30 }),
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isLastPage).toBe(false))
|
||||
act(() => result.current.loadNextPage())
|
||||
await waitFor(() => expect(result.current.data?.plugins).toHaveLength(2))
|
||||
const cachedQuery = queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.find((query) => (query.state.data as { pages?: unknown[] } | undefined)?.pages?.length === 2)
|
||||
if (!cachedQuery) throw new Error('Expected cached second page')
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useRetainFirstInstalledPluginPageOnUnmount(PluginCategoryEnum.tool, 30),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect((cachedQuery.state.data as { pages: unknown[] }).pages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes filtered category pages when leaving the page', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
const filters = { query: 'slack', tags: ['communication'], language: 'en_US' as const }
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('slack-1', PluginCategoryEnum.tool)],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
plugins: [createInstalledPlugin('slack-2', PluginCategoryEnum.tool)],
|
||||
has_more: false,
|
||||
})
|
||||
const wrapper = createWrapper(queryClient)
|
||||
const { result } = renderHook(
|
||||
() => useInstalledPluginList({ category: PluginCategoryEnum.tool, pageSize: 30, filters }),
|
||||
{ wrapper },
|
||||
)
|
||||
await waitFor(() => expect(result.current.isLastPage).toBe(false))
|
||||
act(() => result.current.loadNextPage())
|
||||
await waitFor(() => expect(result.current.data?.plugins).toHaveLength(2))
|
||||
const cachedQuery = queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.find((query) => (query.state.data as { pages?: unknown[] } | undefined)?.pages?.length === 2)
|
||||
if (!cachedQuery) throw new Error('Expected cached filtered pages')
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() => useRetainFirstInstalledPluginPageOnUnmount(PluginCategoryEnum.tool, 30, filters),
|
||||
{ wrapper: createWrapper(queryClient) },
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(
|
||||
queryClient.getQueryCache().find({ queryKey: cachedQuery.queryKey, exact: true }),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePluginTaskList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -511,6 +511,16 @@ describe('normalizeConsoleOpenAPIURL', () => {
|
||||
expect(searchParams.has('tag_ids[0]')).toBe(false)
|
||||
expect(searchParams.has('creators[0]')).toBe(false)
|
||||
})
|
||||
|
||||
it('should serialize plugin category tags as repeated params', () => {
|
||||
const url = normalizeConsoleOpenAPIURL(
|
||||
'https://example.com/console/api/workspaces/current/plugin/tool/list?tags%5B1%5D=rag&tags%5B0%5D=search',
|
||||
)
|
||||
const searchParams = new URL(url).searchParams
|
||||
|
||||
expect(searchParams.getAll('tags')).toEqual(['search', 'rag'])
|
||||
expect(searchParams.has('tags[0]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC query defaults own shared Agent detail fetch behavior.
|
||||
|
||||
@@ -17,6 +17,7 @@ const repeatedQueryArrayRules: readonly QueryArrayCompatibilityRule[] = [
|
||||
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] },
|
||||
{ path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] },
|
||||
{ path: /\/workspaces\/current\/customized-snippets$/, fields: ['tag_ids', 'creators'] },
|
||||
{ path: /\/workspaces\/current\/plugin\/[^/]+\/list$/, fields: ['tags'] },
|
||||
{
|
||||
path: /\/workspaces\/current\/tool-provider\/builtin\/[^/]+\/credential\/info$/,
|
||||
fields: ['include_credential_ids'],
|
||||
|
||||
+178
-58
@@ -1,10 +1,17 @@
|
||||
import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
GetWorkspacesCurrentPluginByCategoryListData,
|
||||
PluginCategoryInstalledPluginResponse,
|
||||
PluginCategoryListResponse,
|
||||
PluginEntity,
|
||||
PluginInstallationItemResponse,
|
||||
PluginListResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
PluginInfoFromMarketPlace,
|
||||
PluginsFromMarketplaceByInfoResponse,
|
||||
PluginsFromMarketplaceResponse,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import type { MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query'
|
||||
import type { InfiniteData, MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
FormOption,
|
||||
ModelProvider,
|
||||
@@ -14,8 +21,6 @@ import type {
|
||||
DebugInfo as DebugInfoTypes,
|
||||
Dependency,
|
||||
GitHubItemAndMarketPlaceDependency,
|
||||
InstalledPluginCategoryListResponse,
|
||||
InstalledPluginListWithTotalResponse,
|
||||
InstallPackageResponse,
|
||||
InstallStatusResponse,
|
||||
MetaData,
|
||||
@@ -31,6 +36,7 @@ import type {
|
||||
VersionInfo,
|
||||
VersionListResponse,
|
||||
} from '@/app/components/plugins/types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { useAtomValue } from 'jotai'
|
||||
@@ -49,7 +55,6 @@ import { consoleQuery } from './client'
|
||||
import { useInvalidateAllBuiltInTools } from './use-tools'
|
||||
|
||||
const NAME_SPACE = 'plugins'
|
||||
const useInstalledPluginListKey = [NAME_SPACE, 'installedPluginList']
|
||||
const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList']
|
||||
|
||||
type PluginTaskListResponse = {
|
||||
@@ -415,7 +420,12 @@ const normalizePluginTriggerDeclaration = (
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): PluginDeclaration => {
|
||||
type InstalledPluginResponse =
|
||||
| PluginCategoryInstalledPluginResponse
|
||||
| PluginEntity
|
||||
| PluginInstallationItemResponse
|
||||
|
||||
const normalizePluginDeclaration = (plugin: InstalledPluginResponse): PluginDeclaration => {
|
||||
const { declaration } = plugin
|
||||
return {
|
||||
plugin_unique_identifier: plugin.plugin_unique_identifier,
|
||||
@@ -445,9 +455,7 @@ const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): Plu
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeInstalledPluginDetail = (
|
||||
plugin: PluginInstallationItemResponse,
|
||||
): PluginDetail => {
|
||||
export const normalizeInstalledPluginDetail = (plugin: InstalledPluginResponse): PluginDetail => {
|
||||
const declaration = normalizePluginDeclaration(plugin)
|
||||
|
||||
return {
|
||||
@@ -603,64 +611,125 @@ export const useFeaturedTriggersRecommendations = (enabled: boolean, limit = 15)
|
||||
|
||||
type UseInstalledPluginListOptions = {
|
||||
category?: PluginCategoryEnum
|
||||
enabled?: boolean
|
||||
filters?: InstalledPluginListFilters
|
||||
pageSize?: number
|
||||
refetchOnMount?: boolean | 'always'
|
||||
}
|
||||
|
||||
export const useInstalledPluginList = (
|
||||
disable?: boolean,
|
||||
pageSize = 100,
|
||||
options?: UseInstalledPluginListOptions,
|
||||
) => {
|
||||
const category = options?.category
|
||||
const fetchPlugins = async ({ pageParam = 1 }) => {
|
||||
const path = category
|
||||
? `/workspaces/current/plugin/${category}/list`
|
||||
: '/workspaces/current/plugin/list'
|
||||
type PluginCategoryListLanguage = NonNullable<
|
||||
NonNullable<GetWorkspacesCurrentPluginByCategoryListData['query']>['language']
|
||||
>
|
||||
|
||||
if (category)
|
||||
return get<InstalledPluginCategoryListResponse>(
|
||||
`${path}?page=${pageParam}&page_size=${pageSize}`,
|
||||
)
|
||||
type InstalledPluginListFilters = {
|
||||
language?: PluginCategoryListLanguage
|
||||
query?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
return get<InstalledPluginListWithTotalResponse>(
|
||||
`${path}?page=${pageParam}&page_size=${pageSize}`,
|
||||
)
|
||||
export const normalizePluginCategoryListLanguage = (locale: string): PluginCategoryListLanguage => {
|
||||
switch (locale) {
|
||||
case 'zh_Hans':
|
||||
case 'ja_JP':
|
||||
case 'pt_BR':
|
||||
case 'en_US':
|
||||
return locale
|
||||
default:
|
||||
return 'en_US'
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isSuccess } =
|
||||
useInfiniteQuery({
|
||||
enabled: !disable,
|
||||
queryKey: category ? [...useInstalledPluginListKey, category] : useInstalledPluginListKey,
|
||||
queryFn: fetchPlugins,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (category)
|
||||
return 'has_more' in lastPage && lastPage.has_more ? pages.length + 1 : undefined
|
||||
|
||||
const totalItems = 'total' in lastPage ? lastPage.total : 0
|
||||
const currentPage = pages.length
|
||||
const itemsLoaded = currentPage * pageSize
|
||||
|
||||
if (itemsLoaded >= totalItems) return
|
||||
|
||||
return currentPage + 1
|
||||
const getInstalledPluginCategoryListInfiniteOptions = ({
|
||||
category,
|
||||
filters,
|
||||
pageSize,
|
||||
refetchOnMount,
|
||||
}: {
|
||||
category: PluginCategoryEnum
|
||||
filters?: InstalledPluginListFilters
|
||||
pageSize: number
|
||||
refetchOnMount?: boolean | 'always'
|
||||
}) => {
|
||||
return consoleQuery.workspaces.current.plugin.byCategory.list.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
params: { category },
|
||||
query: {
|
||||
page: Number(pageParam),
|
||||
page_size: pageSize,
|
||||
...(filters?.query ? { query: filters.query } : {}),
|
||||
...(filters?.tags?.length ? { tags: filters.tags } : {}),
|
||||
...(filters?.language ? { language: filters.language } : {}),
|
||||
},
|
||||
initialPageParam: 1,
|
||||
refetchOnMount: options?.refetchOnMount,
|
||||
})
|
||||
}),
|
||||
getNextPageParam: (lastPage, pages) => (lastPage.has_more ? pages.length + 1 : undefined),
|
||||
initialPageParam: 1,
|
||||
refetchOnMount,
|
||||
})
|
||||
}
|
||||
|
||||
const plugins = data?.pages.flatMap((page) => page.plugins) ?? []
|
||||
const firstPage = data?.pages[0]
|
||||
const builtinTools = firstPage && 'builtin_tools' in firstPage ? firstPage.builtin_tools : []
|
||||
const total = data?.pages[0] && 'total' in data.pages[0] ? data.pages[0].total : plugins.length
|
||||
const getInstalledPluginCategoryListQueryKey = (
|
||||
category: PluginCategoryEnum,
|
||||
pageSize: number,
|
||||
filters?: InstalledPluginListFilters,
|
||||
) => {
|
||||
return getInstalledPluginCategoryListInfiniteOptions({
|
||||
category,
|
||||
filters,
|
||||
pageSize,
|
||||
}).queryKey
|
||||
}
|
||||
|
||||
export const useInstalledPluginList = (options: UseInstalledPluginListOptions = {}) => {
|
||||
const { category, enabled = true, filters, pageSize = 100, refetchOnMount } = options
|
||||
const categoryQuery = useInfiniteQuery({
|
||||
...getInstalledPluginCategoryListInfiniteOptions({
|
||||
category: category ?? PluginCategoryEnum.tool,
|
||||
filters,
|
||||
pageSize,
|
||||
refetchOnMount,
|
||||
}),
|
||||
enabled: enabled && !!category,
|
||||
})
|
||||
const legacyQuery = useInfiniteQuery({
|
||||
...consoleQuery.workspaces.current.plugin.list.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
page: Number(pageParam),
|
||||
page_size: pageSize,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
pages.length * pageSize < lastPage.total ? pages.length + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
refetchOnMount,
|
||||
}),
|
||||
enabled: enabled && !category,
|
||||
})
|
||||
const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isSuccess } =
|
||||
category ? categoryQuery : legacyQuery
|
||||
|
||||
const categoryData = category
|
||||
? (data as InfiniteData<PluginCategoryListResponse, number> | undefined)
|
||||
: undefined
|
||||
const legacyData = category
|
||||
? undefined
|
||||
: (data as InfiniteData<PluginListResponse, number> | undefined)
|
||||
const plugins: PluginDetail[] = categoryData
|
||||
? categoryData.pages.flatMap((page) => page.plugins.map(normalizeInstalledPluginDetail))
|
||||
: (legacyData?.pages.flatMap((page) => page.plugins.map(normalizeInstalledPluginDetail)) ?? [])
|
||||
// The Built-in Tools panel still consumes its legacy Collection model. Keep the
|
||||
// API-contract boundary narrow until that presentation model is migrated.
|
||||
const builtinTools = categoryData?.pages[0]?.builtin_tools as Collection[] | undefined
|
||||
const total = legacyData?.pages[0]?.total ?? plugins.length
|
||||
|
||||
return {
|
||||
data: disable
|
||||
? undefined
|
||||
: {
|
||||
data: enabled
|
||||
? {
|
||||
plugins,
|
||||
builtin_tools: builtinTools,
|
||||
builtin_tools: builtinTools ?? [],
|
||||
total,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
isLastPage: !hasNextPage,
|
||||
loadNextPage: () => {
|
||||
fetchNextPage()
|
||||
@@ -672,12 +741,63 @@ export const useInstalledPluginList = (
|
||||
}
|
||||
}
|
||||
|
||||
const retainFirstInstalledPluginPage = (
|
||||
queryClient: QueryClient,
|
||||
category: PluginCategoryEnum | undefined,
|
||||
pageSize: number,
|
||||
filters?: InstalledPluginListFilters,
|
||||
) => {
|
||||
if (!category) return
|
||||
|
||||
const queryKey = getInstalledPluginCategoryListQueryKey(category, pageSize, filters)
|
||||
const hasActiveFilters = !!filters?.query || !!filters?.tags?.length
|
||||
|
||||
if (hasActiveFilters) {
|
||||
queryClient.removeQueries({ queryKey, exact: true })
|
||||
return
|
||||
}
|
||||
|
||||
void queryClient.cancelQueries({ queryKey }, { revert: false })
|
||||
queryClient.setQueryData<InfiniteData<PluginCategoryListResponse, number>>(
|
||||
queryKey,
|
||||
(cachedData) => {
|
||||
if (!cachedData || cachedData.pages.length <= 1) return cachedData
|
||||
|
||||
return {
|
||||
...cachedData,
|
||||
pages: cachedData.pages.slice(0, 1),
|
||||
pageParams: cachedData.pageParams.slice(0, 1),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const useRetainFirstInstalledPluginPageOnUnmount = (
|
||||
category: PluginCategoryEnum | undefined,
|
||||
pageSize: number,
|
||||
filters?: InstalledPluginListFilters,
|
||||
) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!category) return
|
||||
|
||||
return () => retainFirstInstalledPluginPage(queryClient, category, pageSize, filters)
|
||||
}, [category, filters, pageSize, queryClient])
|
||||
}
|
||||
|
||||
export const useInvalidateInstalledPluginList = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
return () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: useInstalledPluginListKey,
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.installedIds.get.key(),
|
||||
})
|
||||
invalidateAllBuiltInTools()
|
||||
}
|
||||
@@ -730,9 +850,9 @@ export const usePluginDeclarationFromMarketPlace = (pluginUniqueIdentifier: stri
|
||||
})
|
||||
}
|
||||
|
||||
export const useVersionListOfPlugin = (pluginID: string) => {
|
||||
export const useVersionListOfPlugin = (pluginID: string, enabled = true) => {
|
||||
return useQuery<{ data: VersionListResponse }>({
|
||||
enabled: !!pluginID,
|
||||
enabled: enabled && !!pluginID,
|
||||
queryKey: [NAME_SPACE, 'versions', pluginID],
|
||||
queryFn: () =>
|
||||
getMarketplace<{ data: VersionListResponse }>(`/plugins/${pluginID}/versions`, {
|
||||
|
||||
Reference in New Issue
Block a user