Compare commits

...
Author SHA1 Message Date
Jingyi-Dify ad44b01ae1 perf(web): load integration tools by category 2026-07-24 17:40:26 -07:00
Jingyi-Dify baa8d2c358 fix(web): defer model status plugin lookup 2026-07-24 16:39:05 -07:00
Jingyi-Dify b7885fb83a fix(web): optimize data source plugin loading 2026-07-24 16:37:53 -07:00
Jingyi-Dify 46c5093d3e Merge remote-tracking branch 'origin/main' into feat/saas-integrations-initial-load-performance 2026-07-24 15:08:27 -07:00
Jingyi-Dify c06fba49eb perf(web): reduce tool marketplace provider loading 2026-07-23 18:15:50 -07:00
Jingyi-Dify 41aa553461 feat(web): paginate installed integration plugins 2026-07-23 18:15:29 -07:00
autofix-ci[bot]andJingyi-Dify f3593fcbd2 [autofix.ci] apply automated fixes 2026-07-23 17:04:29 -07:00
hjlarryandJingyi-Dify 1738693484 feat: add category-scoped installed plugin ids 2026-07-23 17:04:29 -07:00
hjlarryandJingyi-Dify 9134443fda feat: plugin list query improve 2026-07-23 17:04:29 -07:00
Jingyi-Dify e91bb0cc94 Merge remote-tracking branch 'origin/main' into feat/saas-integrations-initial-load-performance 2026-07-23 16:11:14 -07:00
Jingyi-Dify 08a10f7593 Revert "docs(perf): record integrations load findings"
This reverts commit 08a7a17573.
2026-07-22 19:04:45 -07:00
Jingyi-Dify 08a7a17573 docs(perf): record integrations load findings 2026-07-22 19:01:33 -07:00
Jingyi-Dify d4a8111c34 perf(model-providers): defer secondary data loading 2026-07-22 19:01:22 -07:00
Jingyi-Dify 2ff450fde5 perf(plugins): defer plugin query loading 2026-07-22 19:01:03 -07:00
Jingyi-Dify 5a148aaeea perf(plugins): retain first page after leaving integrations 2026-07-22 12:48:11 -07:00
Jingyi-Dify 36d36954e6 perf(plugins): lazy load plugin icons 2026-07-22 11:16:49 -07:00
Jingyi-Dify 19b6b936e3 perf(integrations): paginate plugin category lists 2026-07-22 11:16:28 -07:00
50 changed files with 1930 additions and 550 deletions
+93 -5
View File
@@ -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
+28 -2
View File
@@ -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(
+26 -5
View File
@@ -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,
+31
View File
@@ -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)
-8
View File
@@ -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(),
})
/**
+1
View File
@@ -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,
@@ -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()
@@ -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,
@@ -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>
)
}
@@ -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', () => {
@@ -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>
)}
</>
@@ -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" />
@@ -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()
@@ -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,
+10 -12
View File
@@ -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')
+17 -16
View File
@@ -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)}
/>
+6 -1
View File
@@ -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(
+2
View File
@@ -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: [],
+306 -18
View File
@@ -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()
+10
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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`, {