mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 07:20:34 +08:00
feat: optimize integrations initial loading (#40118)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
6f2e4d72d3
commit
5dfd83e9ab
@@ -4,9 +4,11 @@ from typing import Any, Literal
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse, ValidationResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -26,8 +28,13 @@ from libs.helper import dump_response, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.model_provider_entities import ProviderResponse
|
||||
from services.entities.model_provider_entities import (
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ProviderResponse,
|
||||
)
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
class ParserModelList(BaseModel):
|
||||
@@ -91,6 +98,22 @@ class ModelProviderListResponse(ResponseModel):
|
||||
data: list[ProviderResponse]
|
||||
|
||||
|
||||
class ModelProviderSummaryListResponse(ResponseModel):
|
||||
data: list[ModelProviderSummaryResponse]
|
||||
plugins: dict[str, ModelProviderPluginSummaryResponse]
|
||||
|
||||
|
||||
class ModelProviderCreditsResponse(ResponseModel):
|
||||
pool_type: Literal["paid", "trial"] | None
|
||||
quota_limit: int | None = Field(description="Credit limit for the effective pool; -1 means unlimited.")
|
||||
quota_used: int | None
|
||||
remaining_credits: int | None = Field(description="Remaining credits; -1 means unlimited.")
|
||||
is_unlimited: bool
|
||||
is_exhausted: bool
|
||||
exhausted_at: int | None
|
||||
next_credit_reset_date: int | None
|
||||
|
||||
|
||||
class ProviderCredentialsResponse(ResponseModel):
|
||||
credentials: dict[str, Any] | None = None
|
||||
|
||||
@@ -114,6 +137,8 @@ register_response_schema_models(
|
||||
console_ns,
|
||||
SimpleResultResponse,
|
||||
ModelProviderListResponse,
|
||||
ModelProviderSummaryListResponse,
|
||||
ModelProviderCreditsResponse,
|
||||
ProviderCredentialsResponse,
|
||||
ValidationResultResponse,
|
||||
ModelProviderPaymentCheckoutUrlResponse,
|
||||
@@ -140,6 +165,40 @@ class ModelProviderListApi(Resource):
|
||||
return ModelProviderListResponse(data=provider_list).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/summary")
|
||||
class ModelProviderSummaryListApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model provider summaries retrieved successfully",
|
||||
console_ns.models[ModelProviderSummaryListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
providers, plugins = ModelProviderService().get_provider_summary_list(tenant_id=tenant_id)
|
||||
return dump_response(
|
||||
ModelProviderSummaryListResponse,
|
||||
{"data": providers, "plugins": plugins},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/credits")
|
||||
class ModelProviderCreditsApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Model provider credits retrieved successfully", console_ns.models[ModelProviderCreditsResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str):
|
||||
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
|
||||
return dump_response(ModelProviderCreditsResponse, credit_pool)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials")
|
||||
class ModelProviderCredentialApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ParserCredentialId))
|
||||
|
||||
@@ -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
|
||||
@@ -90,9 +91,21 @@ class ParserList(BaseModel):
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
|
||||
|
||||
type PluginCategoryListLanguage = Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"]
|
||||
|
||||
|
||||
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):
|
||||
@@ -326,6 +339,10 @@ class PluginListResponse(ResponseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsResponse(ResponseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginVersionsResponse(ResponseModel):
|
||||
versions: Mapping[str, PluginService.LatestPluginCache | None]
|
||||
|
||||
@@ -385,6 +402,7 @@ register_schema_models(
|
||||
console_ns,
|
||||
ParserList,
|
||||
PluginCategoryListQuery,
|
||||
PluginInstalledIdsQuery,
|
||||
PluginAutoUpgradeSettingsPayload,
|
||||
PluginPermissionSettingsPayload,
|
||||
ParserLatest,
|
||||
@@ -421,6 +439,7 @@ register_response_schema_models(
|
||||
PluginDebuggingKeyResponse,
|
||||
PluginDynamicOptionsResponse,
|
||||
PluginInstallationsResponse,
|
||||
PluginInstalledIdsResponse,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
PluginManifestResponse,
|
||||
@@ -478,7 +497,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: PluginCategoryListLanguage) -> str:
|
||||
return value.to_dict()[language] or value.en_US
|
||||
|
||||
|
||||
def _builtin_tool_provider_matches_filters(
|
||||
provider: ToolProviderApiEntity,
|
||||
*,
|
||||
query: str,
|
||||
tags: Sequence[str],
|
||||
language: PluginCategoryListLanguage,
|
||||
) -> 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: PluginCategoryListLanguage = "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)
|
||||
@@ -499,6 +550,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)
|
||||
|
||||
@@ -553,7 +611,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)
|
||||
@@ -561,13 +621,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,
|
||||
@@ -579,6 +652,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__])
|
||||
|
||||
@@ -102,6 +102,19 @@ class PluginModelProviderEntity(BaseModel):
|
||||
declaration: ProviderEntity = Field(description="The declaration of the model provider.")
|
||||
|
||||
|
||||
class PluginModelProviderBinding(BaseModel):
|
||||
"""Lightweight installation metadata for one model provider."""
|
||||
|
||||
provider: str
|
||||
installation_id: str
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
runtime_type: str
|
||||
source: PluginInstallationSource
|
||||
version: str
|
||||
verified: bool = False
|
||||
|
||||
|
||||
class PluginTextEmbeddingNumTokensResponse(BaseModel):
|
||||
"""
|
||||
Response for number of tokens.
|
||||
@@ -215,6 +228,10 @@ class PluginListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsDaemonResponse(BaseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginListWithoutTotalResponse(BaseModel):
|
||||
list: list[PluginEntity]
|
||||
has_more: bool
|
||||
|
||||
@@ -6,6 +6,7 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginBasicBooleanResponse,
|
||||
PluginDaemonInnerError,
|
||||
PluginLLMNumTokensResponse,
|
||||
PluginModelProviderBinding,
|
||||
PluginModelProviderEntity,
|
||||
PluginModelSchemaEntity,
|
||||
PluginStringResultResponse,
|
||||
@@ -48,6 +49,14 @@ class PluginModelClient(BasePluginClient):
|
||||
)
|
||||
return response
|
||||
|
||||
def fetch_model_provider_bindings(self, tenant_id: str) -> Sequence[PluginModelProviderBinding]:
|
||||
"""Fetch only model-provider installation identities from the daemon."""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/models/bindings",
|
||||
list[PluginModelProviderBinding],
|
||||
)
|
||||
|
||||
def get_model_schema(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -48,6 +48,7 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginInstallTaskStatus,
|
||||
PluginListResponse,
|
||||
PluginListWithoutTotalResponse,
|
||||
PluginModelProviderBinding,
|
||||
PluginModelProviderDeclaration,
|
||||
PluginModelProviderEntity,
|
||||
PluginVerification,
|
||||
@@ -81,6 +82,12 @@ class _RedisLock(Protocol):
|
||||
def release(self) -> None: ...
|
||||
|
||||
|
||||
class _ModelPluginIdentity(Protocol):
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
source: PluginInstallationSource
|
||||
|
||||
|
||||
class PluginService:
|
||||
class LatestPluginCache(BaseModel):
|
||||
plugin_id: str
|
||||
@@ -301,7 +308,7 @@ class PluginService:
|
||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
|
||||
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[_ModelPluginIdentity]) -> str | None:
|
||||
remote_model_plugins = sorted(
|
||||
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
|
||||
for plugin in plugins
|
||||
@@ -386,7 +393,7 @@ class PluginService:
|
||||
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
plugins: Sequence[PluginEntity],
|
||||
plugins: Sequence[_ModelPluginIdentity],
|
||||
) -> bool:
|
||||
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
|
||||
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
|
||||
@@ -715,6 +722,26 @@ 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_model_provider_bindings(
|
||||
tenant_id: str, *, client: PluginModelClient | None = None
|
||||
) -> Sequence[PluginModelProviderBinding]:
|
||||
"""Return fresh model bindings and reconcile remote-debug provider metadata before it is read."""
|
||||
model_client = client or PluginModelClient()
|
||||
bindings = model_client.fetch_model_provider_bindings(tenant_id)
|
||||
if PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(tenant_id, bindings):
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
marker = PluginService._get_remote_model_plugin_cache_marker(bindings)
|
||||
PluginService._store_cached_remote_model_plugin_marker(tenant_id, marker)
|
||||
return bindings
|
||||
|
||||
@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.
|
||||
@@ -731,17 +758,33 @@ 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. Only a complete, unfiltered first page may reconcile the model-provider cache; the unpaginated model
|
||||
binding read is the authoritative marker source for larger result sets.
|
||||
"""
|
||||
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 page == 1 and not plugins.has_more and not query and not tags:
|
||||
should_invalidate_model_provider_cache = (
|
||||
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||
tenant_id,
|
||||
|
||||
@@ -10597,6 +10597,20 @@ Update a plugin endpoint
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/credits
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model provider credits retrieved successfully | **application/json**: [ModelProviderCreditsResponse](#modelprovidercreditsresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/summary
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model provider summaries retrieved successfully | **application/json**: [ModelProviderSummaryListResponse](#modelprovidersummarylistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/{provider}/checkout-url
|
||||
#### Parameters
|
||||
|
||||
@@ -11142,6 +11156,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
|
||||
|
||||
@@ -11400,8 +11427,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
|
||||
@@ -19381,6 +19411,30 @@ Enum class for model property key.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| ModelPropertyKey | string | Enum class for model property key. | |
|
||||
|
||||
#### ModelProviderCreditsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| exhausted_at | integer | | Yes |
|
||||
| is_exhausted | boolean | | Yes |
|
||||
| is_unlimited | boolean | | Yes |
|
||||
| next_credit_reset_date | integer | | Yes |
|
||||
| pool_type | string | | Yes |
|
||||
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
|
||||
| quota_used | integer | | Yes |
|
||||
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
|
||||
|
||||
#### ModelProviderCustomConfigurationSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | Yes |
|
||||
| current_credential_id | string | | No |
|
||||
| current_credential_name | string | | No |
|
||||
| current_credential_usable | boolean | | Yes |
|
||||
| has_custom_models | boolean | Whether custom model configuration exists, including saved model credentials. | Yes |
|
||||
| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes |
|
||||
|
||||
#### ModelProviderListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -19393,6 +19447,49 @@ Enum class for model property key.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| payment_link | string | | Yes |
|
||||
|
||||
#### ModelProviderPluginSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| installation_id | string | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| plugin_unique_identifier | string | | Yes |
|
||||
| runtime_type | string | | Yes |
|
||||
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
|
||||
| version | string | | Yes |
|
||||
|
||||
#### ModelProviderSummaryListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [ModelProviderSummaryResponse](#modelprovidersummaryresponse) ] | | Yes |
|
||||
| plugins | object | | Yes |
|
||||
|
||||
#### ModelProviderSummaryResponse
|
||||
|
||||
Fields required to render the collapsed model-provider list.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes |
|
||||
| custom_configuration | [ModelProviderCustomConfigurationSummaryResponse](#modelprovidercustomconfigurationsummaryresponse) | | Yes |
|
||||
| description | [I18nObject](#i18nobject) | | No |
|
||||
| icon_small | [I18nObject](#i18nobject) | | No |
|
||||
| icon_small_dark | [I18nObject](#i18nobject) | | No |
|
||||
| is_configured | boolean | | Yes |
|
||||
| label | [I18nObject](#i18nobject) | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| preferred_provider_type | [ProviderType](#providertype) | | Yes |
|
||||
| provider | string | | Yes |
|
||||
| supported_model_types | [ [ModelType](#modeltype) ] | | Yes |
|
||||
| system_configuration | [ModelProviderSystemConfigurationSummaryResponse](#modelprovidersystemconfigurationsummaryresponse) | | Yes |
|
||||
|
||||
#### ModelProviderSystemConfigurationSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| enabled | boolean | | Yes |
|
||||
|
||||
#### ModelSelectorScope
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -20382,8 +20479,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
|
||||
|
||||
@@ -20584,6 +20684,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 |
|
||||
|
||||
@@ -17,6 +17,7 @@ from core.entities.provider_entities import (
|
||||
QuotaConfiguration,
|
||||
UnaddedModelConfiguration,
|
||||
)
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
FetchFrom,
|
||||
@@ -69,6 +70,67 @@ class SystemConfigurationResponse(BaseModel):
|
||||
quota_configurations: list[QuotaConfiguration] = []
|
||||
|
||||
|
||||
class ModelProviderCustomConfigurationSummaryResponse(BaseModel):
|
||||
status: CustomConfigurationStatus
|
||||
has_custom_models: bool = Field(
|
||||
description="Whether custom model configuration exists, including saved model credentials."
|
||||
)
|
||||
available_credentials: list[CredentialConfiguration]
|
||||
current_credential_id: str | None = None
|
||||
current_credential_name: str | None = None
|
||||
current_credential_usable: bool
|
||||
|
||||
|
||||
class ModelProviderSystemConfigurationSummaryResponse(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ModelProviderPluginSummaryResponse(BaseModel):
|
||||
installation_id: str
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
runtime_type: str
|
||||
source: PluginInstallationSource
|
||||
version: str
|
||||
|
||||
|
||||
class ModelProviderSummaryResponse(BaseModel):
|
||||
"""Fields required to render the collapsed model-provider list."""
|
||||
|
||||
tenant_id: str = Field(exclude=True)
|
||||
provider: str
|
||||
plugin_id: str
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
configurate_methods: list[ConfigurateMethod]
|
||||
preferred_provider_type: ProviderType
|
||||
is_configured: bool
|
||||
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
|
||||
system_configuration: ModelProviderSystemConfigurationSummaryResponse
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_icon_urls(self):
|
||||
url_prefix = (
|
||||
dify_config.CONSOLE_API_URL + f"/console/api/workspaces/{self.tenant_id}/model-providers/{self.provider}"
|
||||
)
|
||||
if self.icon_small is not None:
|
||||
self.icon_small = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small/zh_Hans",
|
||||
)
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
"""
|
||||
Model class for provider response.
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.account import Account
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.model_entities import ModelWithProviderEntity, ProviderModelWithStatusEntity
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.provider_manager import ProviderManager
|
||||
from extensions import ext_hosting_provider
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
|
||||
from models.provider import ProviderType
|
||||
from models.provider import (
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModel,
|
||||
ProviderModelCredential,
|
||||
ProviderType,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.entities.model_provider_entities import (
|
||||
CustomConfigurationResponse,
|
||||
CustomConfigurationStatus,
|
||||
DefaultModelResponse,
|
||||
ModelProviderCustomConfigurationSummaryResponse,
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ModelProviderSystemConfigurationSummaryResponse,
|
||||
ModelWithProviderEntityResponse,
|
||||
ProviderResponse,
|
||||
ProviderWithModelsResponse,
|
||||
@@ -24,6 +48,17 @@ from services.errors.app_model_config import ProviderNotFoundError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ProviderSummaryState:
|
||||
has_custom_provider: bool = False
|
||||
available_credentials: list[CredentialConfiguration] = field(default_factory=list)
|
||||
has_custom_models: bool = False
|
||||
current_credential_id: str | None = None
|
||||
current_credential_name: str | None = None
|
||||
current_credential_usable: bool = False
|
||||
preferred_provider_type: ProviderType | None = None
|
||||
|
||||
|
||||
class ModelProviderService:
|
||||
"""
|
||||
Model Provider Service
|
||||
@@ -132,6 +167,249 @@ class ModelProviderService:
|
||||
|
||||
return provider_responses
|
||||
|
||||
@staticmethod
|
||||
def _load_provider_summary_states(tenant_id: str) -> dict[str, _ProviderSummaryState]:
|
||||
"""Load only the workspace columns required by the collapsed provider list."""
|
||||
with session_factory.create_session() as session:
|
||||
custom_provider_rows = session.execute(
|
||||
select(
|
||||
Provider.provider_name,
|
||||
Provider.credential_id,
|
||||
ProviderCredential.provider_name.label("credential_provider_name"),
|
||||
ProviderCredential.credential_name,
|
||||
)
|
||||
.outerjoin(
|
||||
ProviderCredential,
|
||||
and_(
|
||||
ProviderCredential.id == Provider.credential_id,
|
||||
ProviderCredential.tenant_id == tenant_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_type == ProviderType.CUSTOM,
|
||||
Provider.is_valid.is_(True),
|
||||
)
|
||||
).all()
|
||||
credential_rows = session.execute(
|
||||
select(
|
||||
ProviderCredential.id,
|
||||
ProviderCredential.provider_name,
|
||||
ProviderCredential.credential_name,
|
||||
)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ProviderCredential.created_at.desc(),
|
||||
ProviderCredential.id.desc(),
|
||||
)
|
||||
).all()
|
||||
custom_model_rows = session.execute(
|
||||
select(ProviderModel.provider_name.label("provider_name"))
|
||||
.where(
|
||||
ProviderModel.tenant_id == tenant_id,
|
||||
ProviderModel.is_valid.is_(True),
|
||||
)
|
||||
.union(
|
||||
select(ProviderModelCredential.provider_name.label("provider_name")).where(
|
||||
ProviderModelCredential.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
preferred_provider_rows = session.execute(
|
||||
select(
|
||||
TenantPreferredModelProvider.provider_name,
|
||||
TenantPreferredModelProvider.preferred_provider_type,
|
||||
).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
).all()
|
||||
|
||||
states: defaultdict[str, _ProviderSummaryState] = defaultdict(_ProviderSummaryState)
|
||||
for credential in credential_rows:
|
||||
provider_name = str(ModelProviderID(credential.provider_name))
|
||||
states[provider_name].available_credentials.append(
|
||||
CredentialConfiguration(
|
||||
credential_id=credential.id,
|
||||
credential_name=credential.credential_name,
|
||||
)
|
||||
)
|
||||
|
||||
selected_provider_priorities: dict[str, bool] = {}
|
||||
for provider in custom_provider_rows:
|
||||
provider_name = str(ModelProviderID(provider.provider_name))
|
||||
state = states[provider_name]
|
||||
state.has_custom_provider = True
|
||||
|
||||
is_canonical_row = provider.provider_name == provider_name
|
||||
if provider_name in selected_provider_priorities and not is_canonical_row:
|
||||
continue
|
||||
selected_provider_priorities[provider_name] = is_canonical_row
|
||||
state.current_credential_id = provider.credential_id
|
||||
if (
|
||||
provider.credential_provider_name is not None
|
||||
and str(ModelProviderID(provider.credential_provider_name)) == provider_name
|
||||
):
|
||||
state.current_credential_name = provider.credential_name
|
||||
state.current_credential_usable = True
|
||||
else:
|
||||
state.current_credential_name = None
|
||||
state.current_credential_usable = False
|
||||
|
||||
for model in custom_model_rows:
|
||||
states[str(ModelProviderID(model.provider_name))].has_custom_models = True
|
||||
|
||||
preferred_provider_priorities: dict[str, bool] = {}
|
||||
for preferred_provider in preferred_provider_rows:
|
||||
provider_name = str(ModelProviderID(preferred_provider.provider_name))
|
||||
is_canonical_row = preferred_provider.provider_name == provider_name
|
||||
if provider_name in preferred_provider_priorities and not is_canonical_row:
|
||||
continue
|
||||
preferred_provider_priorities[provider_name] = is_canonical_row
|
||||
states[provider_name].preferred_provider_type = preferred_provider.preferred_provider_type
|
||||
|
||||
return dict(states)
|
||||
|
||||
@staticmethod
|
||||
def _has_system_provider_hosting_configuration(provider: str) -> bool:
|
||||
configuration = ext_hosting_provider.hosting_configuration.provider_map.get(provider)
|
||||
return bool(configuration and configuration.enabled and configuration.quotas)
|
||||
|
||||
@staticmethod
|
||||
def _select_binding(
|
||||
current_binding: PluginModelProviderBinding | None,
|
||||
candidate_binding: PluginModelProviderBinding,
|
||||
) -> PluginModelProviderBinding:
|
||||
"""Prefer a remote-debug runtime when one shadows an installed plugin."""
|
||||
if current_binding is None:
|
||||
return candidate_binding
|
||||
if (
|
||||
candidate_binding.source == PluginInstallationSource.Remote
|
||||
and current_binding.source != PluginInstallationSource.Remote
|
||||
):
|
||||
return candidate_binding
|
||||
return current_binding
|
||||
|
||||
@staticmethod
|
||||
def _get_preferred_provider_type(
|
||||
state: _ProviderSummaryState,
|
||||
*,
|
||||
custom_present: bool,
|
||||
system_enabled: bool,
|
||||
) -> ProviderType:
|
||||
if state.preferred_provider_type is not None:
|
||||
return state.preferred_provider_type
|
||||
if dify_config.EDITION == "CLOUD" and system_enabled:
|
||||
return ProviderType.SYSTEM
|
||||
if custom_present:
|
||||
return ProviderType.CUSTOM
|
||||
if system_enabled:
|
||||
return ProviderType.SYSTEM
|
||||
return ProviderType.CUSTOM
|
||||
|
||||
def get_provider_summary_list(
|
||||
self, tenant_id: str
|
||||
) -> tuple[list[ModelProviderSummaryResponse], dict[str, ModelProviderPluginSummaryResponse]]:
|
||||
"""Build the complete first-screen provider projection without assembling provider configurations."""
|
||||
# Read bindings first: remote-debug identity changes invalidate provider metadata
|
||||
# before the provider cache is consulted.
|
||||
bindings = PluginService.list_model_provider_bindings(tenant_id)
|
||||
provider_entities = PluginService.fetch_plugin_model_providers(tenant_id=tenant_id)
|
||||
states = self._load_provider_summary_states(tenant_id)
|
||||
|
||||
bindings_by_provider: dict[str, PluginModelProviderBinding] = {}
|
||||
for binding in bindings:
|
||||
provider_name = (
|
||||
str(ModelProviderID(binding.provider))
|
||||
if binding.provider.count("/") == 2
|
||||
else str(ModelProviderID(f"{binding.plugin_id}/{binding.provider}"))
|
||||
)
|
||||
bindings_by_provider[provider_name] = self._select_binding(
|
||||
bindings_by_provider.get(provider_name),
|
||||
binding,
|
||||
)
|
||||
|
||||
provider_summaries: list[ModelProviderSummaryResponse] = []
|
||||
emitted_provider_names: set[str] = set()
|
||||
for provider_entity in provider_entities:
|
||||
if is_filtered(
|
||||
include_set=dify_config.POSITION_PROVIDER_INCLUDES_SET,
|
||||
exclude_set=dify_config.POSITION_PROVIDER_EXCLUDES_SET,
|
||||
data=provider_entity,
|
||||
name_func=lambda provider: provider.provider,
|
||||
):
|
||||
continue
|
||||
|
||||
provider_id = ModelProviderID(provider_entity.provider)
|
||||
provider_name = str(provider_id)
|
||||
if provider_name in emitted_provider_names:
|
||||
continue
|
||||
emitted_provider_names.add(provider_name)
|
||||
|
||||
state = states.get(provider_name, _ProviderSummaryState())
|
||||
custom_configured = (
|
||||
state.has_custom_provider and bool(state.available_credentials)
|
||||
) or state.has_custom_models
|
||||
custom_present = state.has_custom_provider or state.has_custom_models
|
||||
provider_binding = bindings_by_provider.get(provider_name)
|
||||
system_enabled = bool(
|
||||
provider_binding
|
||||
and self._has_system_provider_hosting_configuration(provider_name)
|
||||
and provider_binding.source != PluginInstallationSource.Package
|
||||
and provider_binding.verified
|
||||
)
|
||||
preferred_provider_type = self._get_preferred_provider_type(
|
||||
state,
|
||||
custom_present=custom_present,
|
||||
system_enabled=system_enabled,
|
||||
)
|
||||
|
||||
provider_summaries.append(
|
||||
ModelProviderSummaryResponse(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_name,
|
||||
plugin_id=provider_id.plugin_id,
|
||||
label=provider_entity.label,
|
||||
description=provider_entity.description,
|
||||
icon_small=provider_entity.icon_small,
|
||||
icon_small_dark=provider_entity.icon_small_dark,
|
||||
supported_model_types=provider_entity.supported_model_types,
|
||||
configurate_methods=provider_entity.configurate_methods,
|
||||
preferred_provider_type=preferred_provider_type,
|
||||
is_configured=custom_configured or system_enabled,
|
||||
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
|
||||
status=CustomConfigurationStatus.ACTIVE
|
||||
if custom_configured
|
||||
else CustomConfigurationStatus.NO_CONFIGURE,
|
||||
has_custom_models=state.has_custom_models,
|
||||
available_credentials=state.available_credentials,
|
||||
current_credential_id=state.current_credential_id,
|
||||
current_credential_name=state.current_credential_name,
|
||||
current_credential_usable=state.current_credential_usable,
|
||||
),
|
||||
system_configuration=ModelProviderSystemConfigurationSummaryResponse(
|
||||
enabled=system_enabled,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
plugin_bindings: dict[str, PluginModelProviderBinding] = {}
|
||||
for binding in bindings_by_provider.values():
|
||||
plugin_bindings[binding.plugin_id] = self._select_binding(
|
||||
plugin_bindings.get(binding.plugin_id),
|
||||
binding,
|
||||
)
|
||||
|
||||
plugin_summaries = {
|
||||
plugin_id: ModelProviderPluginSummaryResponse(
|
||||
installation_id=binding.installation_id,
|
||||
plugin_id=binding.plugin_id,
|
||||
plugin_unique_identifier=binding.plugin_unique_identifier,
|
||||
runtime_type=binding.runtime_type,
|
||||
source=binding.source,
|
||||
version=binding.version,
|
||||
)
|
||||
for plugin_id, binding in plugin_bindings.items()
|
||||
}
|
||||
return provider_summaries, plugin_summaries
|
||||
|
||||
def get_models_by_provider(self, tenant_id: str, provider: str) -> list[ModelWithProviderEntityResponse]:
|
||||
"""
|
||||
get provider models.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -7,9 +10,37 @@ from enums.cloud_plan import CloudPlan
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveCreditPool:
|
||||
plan: str | None = None
|
||||
pool_type: Literal["paid", "trial"] | None = None
|
||||
quota_limit: int | None = None
|
||||
quota_used: int | None = None
|
||||
exhausted_at: int | None = None
|
||||
next_credit_reset_date: int | None = None
|
||||
|
||||
@property
|
||||
def remaining_credits(self) -> int | None:
|
||||
if self.quota_limit is None or self.quota_used is None:
|
||||
return None
|
||||
if self.is_unlimited:
|
||||
return -1
|
||||
return max(0, self.quota_limit - self.quota_used)
|
||||
|
||||
@property
|
||||
def is_unlimited(self) -> bool:
|
||||
return self.quota_limit == -1
|
||||
|
||||
@property
|
||||
def is_exhausted(self) -> bool:
|
||||
remaining_credits = self.remaining_credits
|
||||
return not self.is_unlimited and (remaining_credits is None or remaining_credits <= 0)
|
||||
|
||||
|
||||
def _set_credit_pool_info(
|
||||
tenant_info: dict[str, object], *, quota_limit: int, quota_used: int, exhausted_at: int | None = None
|
||||
) -> None:
|
||||
@@ -20,6 +51,51 @@ def _set_credit_pool_info(
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
@classmethod
|
||||
def get_effective_credit_pool(cls, tenant_id: str, *, session: Session) -> EffectiveCreditPool:
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return EffectiveCreditPool()
|
||||
|
||||
billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True)
|
||||
subscription_plan: str = billing_info["subscription"]["plan"]
|
||||
|
||||
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
|
||||
|
||||
effective_pool = None
|
||||
effective_pool_type: Literal["paid", "trial"] = "trial"
|
||||
if subscription_plan != CloudPlan.SANDBOX:
|
||||
paid_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="paid", session=session)
|
||||
if paid_pool is not None and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used):
|
||||
effective_pool = paid_pool
|
||||
effective_pool_type = "paid"
|
||||
|
||||
if effective_pool is None:
|
||||
effective_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial", session=session)
|
||||
|
||||
if effective_pool is None:
|
||||
return EffectiveCreditPool(
|
||||
plan=subscription_plan if billing_info["enabled"] else None,
|
||||
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
|
||||
)
|
||||
|
||||
exhausted_at = effective_pool.exhausted_at if isinstance(effective_pool, CreditPoolBalance) else None
|
||||
if not (
|
||||
isinstance(exhausted_at, int)
|
||||
and exhausted_at > 0
|
||||
and effective_pool.quota_limit > 0
|
||||
and effective_pool.quota_used >= effective_pool.quota_limit
|
||||
):
|
||||
exhausted_at = None
|
||||
|
||||
return EffectiveCreditPool(
|
||||
plan=subscription_plan if billing_info["enabled"] else None,
|
||||
pool_type=effective_pool_type,
|
||||
quota_limit=effective_pool.quota_limit,
|
||||
quota_used=effective_pool.quota_used,
|
||||
exhausted_at=exhausted_at,
|
||||
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_tenant_info(cls, tenant: Tenant, session: Session):
|
||||
if not tenant:
|
||||
|
||||
@@ -12,12 +12,15 @@ from configs import dify_config
|
||||
from controllers.console.workspace.model_providers import (
|
||||
ModelProviderCredentialApi,
|
||||
ModelProviderCredentialSwitchApi,
|
||||
ModelProviderCreditsApi,
|
||||
ModelProviderIconApi,
|
||||
ModelProviderListApi,
|
||||
ModelProviderPaymentCheckoutUrlApi,
|
||||
ModelProviderSummaryListApi,
|
||||
ModelProviderValidateApi,
|
||||
PreferredProviderTypeUpdateApi,
|
||||
)
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
@@ -27,9 +30,14 @@ from models.provider import ProviderType
|
||||
from services.entities.model_provider_entities import (
|
||||
CustomConfigurationResponse,
|
||||
CustomConfigurationStatus,
|
||||
ModelProviderCustomConfigurationSummaryResponse,
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ModelProviderSystemConfigurationSummaryResponse,
|
||||
ProviderResponse,
|
||||
SystemConfigurationResponse,
|
||||
)
|
||||
from services.workspace_service import EffectiveCreditPool
|
||||
|
||||
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
||||
INVALID_UUID = "123"
|
||||
@@ -140,6 +148,136 @@ class TestModelProviderListApi:
|
||||
assert result == {"data": []}
|
||||
|
||||
|
||||
class TestModelProviderSummaryListApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
api = ModelProviderSummaryListApi()
|
||||
method = unwrap(api.get)
|
||||
provider = ModelProviderSummaryResponse(
|
||||
tenant_id="tenant1",
|
||||
provider="langgenius/openai/openai",
|
||||
plugin_id="langgenius/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=I18nObject(en_US="OpenAI models"),
|
||||
icon_small=I18nObject(en_US="icon.svg"),
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
is_configured=True,
|
||||
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
|
||||
status=CustomConfigurationStatus.ACTIVE,
|
||||
has_custom_models=True,
|
||||
available_credentials=[
|
||||
CredentialConfiguration(
|
||||
credential_id=VALID_UUID,
|
||||
credential_name="production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="223e4567-e89b-12d3-a456-426614174000",
|
||||
credential_name="backup",
|
||||
),
|
||||
],
|
||||
current_credential_id=VALID_UUID,
|
||||
current_credential_name="production",
|
||||
current_credential_usable=True,
|
||||
),
|
||||
system_configuration=ModelProviderSystemConfigurationSummaryResponse(enabled=False),
|
||||
)
|
||||
plugin = ModelProviderPluginSummaryResponse(
|
||||
installation_id="installation-1",
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source="marketplace",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.workspace.model_providers.ModelProviderService.get_provider_summary_list",
|
||||
return_value=([provider], {"langgenius/openai": plugin}),
|
||||
) as get_provider_summary_list,
|
||||
):
|
||||
result = method(api, "tenant1")
|
||||
|
||||
get_provider_summary_list.assert_called_once_with(tenant_id="tenant1")
|
||||
assert result["data"][0]["provider"] == "langgenius/openai/openai"
|
||||
assert "tenant_id" not in result["data"][0]
|
||||
assert result["data"][0]["custom_configuration"] == {
|
||||
"status": "active",
|
||||
"has_custom_models": True,
|
||||
"available_credentials": [
|
||||
{
|
||||
"credential_id": VALID_UUID,
|
||||
"credential_name": "production",
|
||||
},
|
||||
{
|
||||
"credential_id": "223e4567-e89b-12d3-a456-426614174000",
|
||||
"credential_name": "backup",
|
||||
},
|
||||
],
|
||||
"current_credential_id": VALID_UUID,
|
||||
"current_credential_name": "production",
|
||||
"current_credential_usable": True,
|
||||
}
|
||||
assert result["plugins"]["langgenius/openai"]["installation_id"] == "installation-1"
|
||||
|
||||
|
||||
class TestModelProviderCreditsApi:
|
||||
def test_get_success(self):
|
||||
api = ModelProviderCreditsApi()
|
||||
method = unwrap(api.get)
|
||||
session = SimpleNamespace()
|
||||
credit_pool = EffectiveCreditPool(
|
||||
plan="team",
|
||||
pool_type="paid",
|
||||
quota_limit=-1,
|
||||
quota_used=999,
|
||||
next_credit_reset_date=1775001600,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"controllers.console.workspace.model_providers.WorkspaceService.get_effective_credit_pool",
|
||||
return_value=credit_pool,
|
||||
) as get_effective_credit_pool:
|
||||
result = method(api, session, "tenant1")
|
||||
|
||||
get_effective_credit_pool.assert_called_once_with("tenant1", session=session)
|
||||
assert result == {
|
||||
"pool_type": "paid",
|
||||
"quota_limit": -1,
|
||||
"quota_used": 999,
|
||||
"remaining_credits": -1,
|
||||
"is_unlimited": True,
|
||||
"is_exhausted": False,
|
||||
"exhausted_at": None,
|
||||
"next_credit_reset_date": 1775001600,
|
||||
}
|
||||
|
||||
def test_get_without_effective_pool(self):
|
||||
api = ModelProviderCreditsApi()
|
||||
method = unwrap(api.get)
|
||||
session = SimpleNamespace()
|
||||
|
||||
with patch(
|
||||
"controllers.console.workspace.model_providers.WorkspaceService.get_effective_credit_pool",
|
||||
return_value=EffectiveCreditPool(),
|
||||
):
|
||||
result = method(api, session, "tenant1")
|
||||
|
||||
assert result == {
|
||||
"pool_type": None,
|
||||
"quota_limit": None,
|
||||
"quota_used": None,
|
||||
"remaining_credits": None,
|
||||
"is_unlimited": False,
|
||||
"is_exhausted": True,
|
||||
"exhausted_at": None,
|
||||
"next_credit_reset_date": None,
|
||||
}
|
||||
|
||||
|
||||
class TestModelProviderCredentialApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
api = ModelProviderCredentialApi()
|
||||
|
||||
@@ -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()
|
||||
@@ -739,6 +801,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()
|
||||
|
||||
@@ -630,6 +630,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/"
|
||||
)
|
||||
@@ -657,3 +662,114 @@ 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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_console_model_provider_summary_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/model-providers/summary"]["get"]
|
||||
assert operation.get("parameters", []) == []
|
||||
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
response_schema = payload["components"]["schemas"][response_ref]
|
||||
assert response_schema["required"] == ["data", "plugins"]
|
||||
assert response_schema["properties"]["data"]["items"]["$ref"] == (
|
||||
"#/components/schemas/ModelProviderSummaryResponse"
|
||||
)
|
||||
assert response_schema["properties"]["plugins"]["additionalProperties"]["$ref"] == (
|
||||
"#/components/schemas/ModelProviderPluginSummaryResponse"
|
||||
)
|
||||
|
||||
provider_properties = payload["components"]["schemas"]["ModelProviderSummaryResponse"]["properties"]
|
||||
assert set(provider_properties) == {
|
||||
"configurate_methods",
|
||||
"custom_configuration",
|
||||
"description",
|
||||
"icon_small",
|
||||
"icon_small_dark",
|
||||
"is_configured",
|
||||
"label",
|
||||
"plugin_id",
|
||||
"preferred_provider_type",
|
||||
"provider",
|
||||
"supported_model_types",
|
||||
"system_configuration",
|
||||
}
|
||||
assert "provider_credential_schema" not in provider_properties
|
||||
assert "model_credential_schema" not in provider_properties
|
||||
|
||||
custom_configuration_schema = payload["components"]["schemas"]["ModelProviderCustomConfigurationSummaryResponse"]
|
||||
custom_configuration_properties = custom_configuration_schema["properties"]
|
||||
assert set(custom_configuration_schema["required"]) == {
|
||||
"available_credentials",
|
||||
"current_credential_usable",
|
||||
"has_custom_models",
|
||||
"status",
|
||||
}
|
||||
assert set(custom_configuration_properties) == {
|
||||
"available_credentials",
|
||||
"current_credential_id",
|
||||
"current_credential_name",
|
||||
"current_credential_usable",
|
||||
"has_custom_models",
|
||||
"status",
|
||||
}
|
||||
assert custom_configuration_properties["available_credentials"]["items"]["$ref"] == (
|
||||
"#/components/schemas/CredentialConfiguration"
|
||||
)
|
||||
assert "has_credentials" not in custom_configuration_properties
|
||||
|
||||
credential_properties = payload["components"]["schemas"]["CredentialConfiguration"]["properties"]
|
||||
assert set(credential_properties) == {
|
||||
"credential_id",
|
||||
"credential_name",
|
||||
}
|
||||
assert "encrypted_config" not in credential_properties
|
||||
|
||||
plugin_properties = payload["components"]["schemas"]["ModelProviderPluginSummaryResponse"]["properties"]
|
||||
assert set(plugin_properties) == {
|
||||
"installation_id",
|
||||
"plugin_id",
|
||||
"plugin_unique_identifier",
|
||||
"runtime_type",
|
||||
"source",
|
||||
"version",
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ class TestPluginModelClient:
|
||||
)
|
||||
assert request_mock.call_args.kwargs["params"] == {"page": 1, "page_size": 256}
|
||||
|
||||
def test_fetch_model_provider_bindings(self, mocker: MockerFixture):
|
||||
client = PluginModelClient()
|
||||
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["binding-a"])
|
||||
|
||||
result = client.fetch_model_provider_bindings("tenant-1")
|
||||
|
||||
assert result == ["binding-a"]
|
||||
assert request_mock.call_args.args[:2] == (
|
||||
"GET",
|
||||
"plugin/tenant-1/management/models/bindings",
|
||||
)
|
||||
assert "params" not in request_mock.call_args.kwargs
|
||||
|
||||
def test_get_model_schema(self, mocker: MockerFixture):
|
||||
client = PluginModelClient()
|
||||
schema = SimpleNamespace(name="schema")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -881,7 +881,105 @@ 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_list_model_provider_bindings_reconciles_remote_provider_cache(self) -> None:
|
||||
"""The summary binding read owns the remote marker once the full category list leaves the first-load path."""
|
||||
remote_binding = _build_remote_model_plugin()
|
||||
client = MagicMock()
|
||||
client.fetch_model_provider_bindings.return_value = [remote_binding]
|
||||
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MODULE}.PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins",
|
||||
return_value=True,
|
||||
) as should_invalidate,
|
||||
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,
|
||||
):
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_model_provider_bindings("tenant-1", client=client)
|
||||
|
||||
assert result == [remote_binding]
|
||||
client.fetch_model_provider_bindings.assert_called_once_with("tenant-1")
|
||||
should_invalidate.assert_called_once_with("tenant-1", [remote_binding])
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
|
||||
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."""
|
||||
with (
|
||||
@@ -925,7 +1023,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)
|
||||
@@ -992,14 +1096,42 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
|
||||
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
|
||||
"""The current model category result clears provider cache when the previous debug model disappears."""
|
||||
@pytest.mark.parametrize(("page", "has_more"), [(1, True), (2, False)])
|
||||
def test_list_model_category_does_not_reconcile_partial_page(self, page: int, has_more: bool) -> None:
|
||||
"""Only an unfiltered, complete first page may write the remote model marker."""
|
||||
installed_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=has_more)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(
|
||||
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
|
||||
),
|
||||
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, page, 100)
|
||||
|
||||
assert result is plugins
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_not_called()
|
||||
|
||||
def test_list_model_category_complete_first_page_reconciles_remote_plugin_disconnect(self) -> None:
|
||||
installed_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=False)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
|
||||
@@ -5,12 +5,16 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from core.entities.model_entities import ModelStatus
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
from models.provider import ProviderType
|
||||
from services import model_provider_service as service_module
|
||||
from services.errors.app_model_config import ProviderNotFoundError
|
||||
from services.model_provider_service import ModelProviderService
|
||||
from services.model_provider_service import ModelProviderService, _ProviderSummaryState
|
||||
|
||||
|
||||
def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]:
|
||||
@@ -59,6 +63,26 @@ def _build_provider_configuration(
|
||||
)
|
||||
|
||||
|
||||
def _build_model_provider_binding(
|
||||
source: PluginInstallationSource,
|
||||
*,
|
||||
installation_id: str = "installation-1",
|
||||
plugin_id: str = "langgenius/openai",
|
||||
plugin_unique_identifier: str = "langgenius/openai:1.0.0@checksum",
|
||||
verified: bool = True,
|
||||
) -> PluginModelProviderBinding:
|
||||
return PluginModelProviderBinding(
|
||||
provider="openai",
|
||||
installation_id=installation_id,
|
||||
plugin_id=plugin_id,
|
||||
plugin_unique_identifier=plugin_unique_identifier,
|
||||
runtime_type="remote" if source == PluginInstallationSource.Remote else "local",
|
||||
source=source,
|
||||
version="1.0.0",
|
||||
verified=verified,
|
||||
)
|
||||
|
||||
|
||||
class TestModelProviderServiceConfiguration:
|
||||
def test__get_provider_configuration_should_return_configuration_when_provider_exists(self) -> None:
|
||||
service, manager = _create_service_with_mocked_manager()
|
||||
@@ -96,6 +120,352 @@ class TestModelProviderServiceConfiguration:
|
||||
assert result[0].provider == "openai"
|
||||
assert result[0].custom_configuration.status.value == "no-configure"
|
||||
|
||||
def test_get_provider_summary_list_uses_lightweight_state_and_plugin_bindings(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = ModelProviderService()
|
||||
provider = SimpleNamespace(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=I18nObject(en_US="OpenAI models"),
|
||||
icon_small=I18nObject(en_US="icon.svg"),
|
||||
icon_small_dark=I18nObject(en_US="icon-dark.svg"),
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
binding = SimpleNamespace(
|
||||
provider="openai",
|
||||
plugin_id="langgenius/openai",
|
||||
installation_id="installation-1",
|
||||
plugin_unique_identifier="langgenius/openai:1.2.3@checksum",
|
||||
runtime_type="local",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
version="1.2.3",
|
||||
verified=True,
|
||||
)
|
||||
state = _ProviderSummaryState(
|
||||
has_custom_provider=True,
|
||||
available_credentials=[
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-1",
|
||||
credential_name="Production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-2",
|
||||
credential_name="Backup",
|
||||
),
|
||||
],
|
||||
has_custom_models=True,
|
||||
current_credential_id="credential-1",
|
||||
current_credential_name="Production",
|
||||
current_credential_usable=True,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
call_order: list[str] = []
|
||||
manager_constructor = MagicMock(side_effect=AssertionError("summary must not construct ProviderManager"))
|
||||
monkeypatch.setattr(service, "_get_provider_manager", manager_constructor)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"list_model_provider_bindings",
|
||||
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("bindings") or [binding]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"fetch_plugin_model_providers",
|
||||
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("providers") or [provider]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_load_provider_summary_states",
|
||||
MagicMock(return_value={provider.provider: state}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.ext_hosting_provider.hosting_configuration,
|
||||
"provider_map",
|
||||
{provider.provider: SimpleNamespace(enabled=True, quotas=[SimpleNamespace()])},
|
||||
)
|
||||
|
||||
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
|
||||
|
||||
assert len(providers) == 1
|
||||
assert providers[0].provider == provider.provider
|
||||
assert providers[0].plugin_id == "langgenius/openai"
|
||||
assert providers[0].is_configured is True
|
||||
assert providers[0].custom_configuration.available_credentials == [
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-1",
|
||||
credential_name="Production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-2",
|
||||
credential_name="Backup",
|
||||
),
|
||||
]
|
||||
assert providers[0].custom_configuration.has_custom_models is True
|
||||
assert providers[0].custom_configuration.current_credential_name == "Production"
|
||||
assert providers[0].custom_configuration.current_credential_usable is True
|
||||
assert providers[0].system_configuration.enabled is True
|
||||
assert plugins["langgenius/openai"].installation_id == "installation-1"
|
||||
assert plugins["langgenius/openai"].version == "1.2.3"
|
||||
assert call_order == ["bindings", "providers"]
|
||||
manager_constructor.assert_not_called()
|
||||
|
||||
def test_get_provider_summary_list_enables_system_only_for_verified_hosted_non_package_bindings(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
marketplace_binding = _build_model_provider_binding(PluginInstallationSource.Marketplace)
|
||||
package_binding = _build_model_provider_binding(
|
||||
PluginInstallationSource.Package,
|
||||
installation_id="installation-package",
|
||||
plugin_id="langgenius/package",
|
||||
plugin_unique_identifier="langgenius/package:1.0.0@checksum",
|
||||
)
|
||||
unhosted_binding = _build_model_provider_binding(
|
||||
PluginInstallationSource.Marketplace,
|
||||
installation_id="installation-unhosted",
|
||||
plugin_id="langgenius/unhosted",
|
||||
plugin_unique_identifier="langgenius/unhosted:1.0.0@checksum",
|
||||
)
|
||||
remote_binding = _build_model_provider_binding(
|
||||
PluginInstallationSource.Remote,
|
||||
installation_id="installation-remote",
|
||||
plugin_id="langgenius/remote",
|
||||
plugin_unique_identifier="langgenius/remote:1.0.0@checksum",
|
||||
)
|
||||
unverified_binding = _build_model_provider_binding(
|
||||
PluginInstallationSource.Marketplace,
|
||||
installation_id="installation-unverified",
|
||||
plugin_id="langgenius/unverified",
|
||||
plugin_unique_identifier="langgenius/unverified:1.0.0@checksum",
|
||||
verified=False,
|
||||
)
|
||||
bindings = [
|
||||
marketplace_binding,
|
||||
package_binding,
|
||||
unhosted_binding,
|
||||
remote_binding,
|
||||
unverified_binding,
|
||||
]
|
||||
provider_entities = [
|
||||
SimpleNamespace(
|
||||
provider=f"{binding.plugin_id}/openai",
|
||||
label=I18nObject(en_US=binding.plugin_id),
|
||||
description=None,
|
||||
icon_small=None,
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[],
|
||||
)
|
||||
for binding in bindings
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
service_module.ext_hosting_provider.hosting_configuration,
|
||||
"provider_map",
|
||||
{
|
||||
"langgenius/openai/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
|
||||
"langgenius/package/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
|
||||
"langgenius/remote/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
|
||||
"langgenius/unverified/openai": SimpleNamespace(enabled=True, quotas=[SimpleNamespace()]),
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"list_model_provider_bindings",
|
||||
MagicMock(return_value=bindings),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"fetch_plugin_model_providers",
|
||||
MagicMock(return_value=provider_entities),
|
||||
)
|
||||
monkeypatch.setattr(ModelProviderService, "_load_provider_summary_states", MagicMock(return_value={}))
|
||||
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
|
||||
|
||||
providers, _ = ModelProviderService().get_provider_summary_list("tenant-1")
|
||||
|
||||
assert {provider.provider: provider.system_configuration.enabled for provider in providers} == {
|
||||
"langgenius/openai/openai": True,
|
||||
"langgenius/package/openai": False,
|
||||
"langgenius/unhosted/openai": False,
|
||||
"langgenius/remote/openai": True,
|
||||
"langgenius/unverified/openai": False,
|
||||
}
|
||||
|
||||
def test_model_provider_binding_without_verified_field_fails_closed(self) -> None:
|
||||
binding = PluginModelProviderBinding.model_validate(
|
||||
{
|
||||
"provider": "openai",
|
||||
"installation_id": "installation-1",
|
||||
"plugin_id": "langgenius/openai",
|
||||
"plugin_unique_identifier": "langgenius/openai:1.0.0@checksum",
|
||||
"runtime_type": "local",
|
||||
"source": "marketplace",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
)
|
||||
|
||||
assert binding.verified is False
|
||||
|
||||
def test_get_provider_summary_list_returns_all_unique_provider_metadata(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = ModelProviderService()
|
||||
llm_provider = SimpleNamespace(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=None,
|
||||
icon_small=None,
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[],
|
||||
)
|
||||
embedding_provider = SimpleNamespace(
|
||||
provider="langgenius/embedding/embedding",
|
||||
label=I18nObject(en_US="Embedding"),
|
||||
description=None,
|
||||
icon_small=None,
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.TEXT_EMBEDDING],
|
||||
configurate_methods=[],
|
||||
)
|
||||
llm_binding = SimpleNamespace(
|
||||
provider="openai",
|
||||
plugin_id="langgenius/openai",
|
||||
installation_id="installation-openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source=service_module.PluginInstallationSource.Marketplace,
|
||||
version="1.0.0",
|
||||
verified=False,
|
||||
)
|
||||
embedding_binding = SimpleNamespace(
|
||||
provider="embedding",
|
||||
plugin_id="langgenius/embedding",
|
||||
installation_id="installation-embedding",
|
||||
plugin_unique_identifier="langgenius/embedding:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source=service_module.PluginInstallationSource.Marketplace,
|
||||
version="1.0.0",
|
||||
verified=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"list_model_provider_bindings",
|
||||
MagicMock(return_value=[llm_binding, embedding_binding]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"fetch_plugin_model_providers",
|
||||
MagicMock(return_value=[llm_provider, llm_provider, embedding_provider]),
|
||||
)
|
||||
monkeypatch.setattr(service, "_load_provider_summary_states", MagicMock(return_value={}))
|
||||
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
|
||||
|
||||
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
|
||||
|
||||
assert [provider.provider for provider in providers] == [
|
||||
"langgenius/openai/openai",
|
||||
"langgenius/embedding/embedding",
|
||||
]
|
||||
assert providers[0].is_configured is False
|
||||
assert providers[0].custom_configuration.status.value == "no-configure"
|
||||
assert providers[0].custom_configuration.has_custom_models is False
|
||||
assert providers[0].custom_configuration.available_credentials == []
|
||||
assert set(plugins) == {"langgenius/openai", "langgenius/embedding"}
|
||||
|
||||
def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
|
||||
state = _ProviderSummaryState(has_custom_provider=True)
|
||||
|
||||
preferred_provider_type = ModelProviderService._get_preferred_provider_type(
|
||||
state,
|
||||
custom_present=True,
|
||||
system_enabled=True,
|
||||
)
|
||||
|
||||
assert preferred_provider_type == ProviderType.CUSTOM
|
||||
|
||||
def test_load_provider_summary_states_reads_only_lightweight_columns(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
canonical_provider = "langgenius/openai/openai"
|
||||
session = MagicMock()
|
||||
session.execute.side_effect = [
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
provider_name="openai",
|
||||
credential_id="credential-legacy",
|
||||
credential_provider_name="openai",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
SimpleNamespace(
|
||||
provider_name=canonical_provider,
|
||||
credential_id="credential-current",
|
||||
credential_provider_name=canonical_provider,
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
),
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
id="credential-legacy",
|
||||
provider_name="openai",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="credential-current",
|
||||
provider_name=canonical_provider,
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
),
|
||||
SimpleNamespace(all=lambda: [SimpleNamespace(provider_name="openai")]),
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
provider_name=canonical_provider,
|
||||
preferred_provider_type=ProviderType.SYSTEM,
|
||||
)
|
||||
]
|
||||
),
|
||||
]
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
create_session = MagicMock(return_value=session_context)
|
||||
monkeypatch.setattr(service_module.session_factory, "create_session", create_session)
|
||||
|
||||
states = ModelProviderService._load_provider_summary_states("tenant-1")
|
||||
|
||||
state = states[canonical_provider]
|
||||
assert state.has_custom_provider is True
|
||||
assert state.available_credentials == [
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-legacy",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-current",
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
assert state.has_custom_models is True
|
||||
assert state.current_credential_id == "credential-current"
|
||||
assert state.current_credential_name == "Production"
|
||||
assert state.current_credential_usable is True
|
||||
assert state.preferred_provider_type == ProviderType.SYSTEM
|
||||
|
||||
statements = [str(execute_call.args[0]) for execute_call in session.execute.call_args_list]
|
||||
assert len(statements) == 4
|
||||
assert all("encrypted_config" not in statement for statement in statements)
|
||||
assert "count(" not in statements[1].lower()
|
||||
assert "provider_credentials.id" in statements[1]
|
||||
assert "provider_credentials.credential_name" in statements[1]
|
||||
assert "ORDER BY provider_credentials.created_at DESC, provider_credentials.id DESC" in statements[1]
|
||||
assert "provider_model_credentials" in statements[2]
|
||||
|
||||
def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None:
|
||||
service, manager = _create_service_with_mocked_manager()
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from services.credit_pool_service import CreditPoolBalance
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("quota_limit", "quota_used", "remaining_credits", "is_unlimited"),
|
||||
[(500, 120, 380, False), (-1, 999, -1, True)],
|
||||
)
|
||||
def test_get_effective_credit_pool_prefers_available_paid_pool(
|
||||
quota_limit: int, quota_used: int, remaining_credits: int, is_unlimited: bool
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
paid_pool = CreditPoolBalance(
|
||||
tenant_id="tenant-1",
|
||||
pool_type="paid",
|
||||
quota_limit=quota_limit,
|
||||
quota_used=quota_used,
|
||||
)
|
||||
billing_info = {
|
||||
"enabled": True,
|
||||
"subscription": {"plan": CloudPlan.TEAM},
|
||||
"next_credit_reset_date": 1775001600,
|
||||
}
|
||||
config = SimpleNamespace(BILLING_ENABLED=True)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.dify_config", config),
|
||||
patch("services.workspace_service.BillingService.get_info", return_value=billing_info),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=paid_pool) as get_pool,
|
||||
):
|
||||
result = WorkspaceService.get_effective_credit_pool("tenant-1", session=session)
|
||||
|
||||
get_pool.assert_called_once_with(tenant_id="tenant-1", pool_type="paid", session=session)
|
||||
assert result.pool_type == "paid"
|
||||
assert result.quota_limit == quota_limit
|
||||
assert result.quota_used == quota_used
|
||||
assert result.remaining_credits == remaining_credits
|
||||
assert result.is_unlimited is is_unlimited
|
||||
assert result.is_exhausted is False
|
||||
assert result.next_credit_reset_date == 1775001600
|
||||
|
||||
|
||||
def test_get_effective_credit_pool_exposes_exhausted_trial_pool() -> None:
|
||||
session = MagicMock()
|
||||
trial_pool = CreditPoolBalance(
|
||||
tenant_id="tenant-1",
|
||||
pool_type="trial",
|
||||
quota_limit=200,
|
||||
quota_used=200,
|
||||
exhausted_at=1772323200,
|
||||
)
|
||||
billing_info = {
|
||||
"enabled": True,
|
||||
"subscription": {"plan": CloudPlan.SANDBOX},
|
||||
}
|
||||
config = SimpleNamespace(BILLING_ENABLED=True)
|
||||
|
||||
with (
|
||||
patch("services.workspace_service.dify_config", config),
|
||||
patch("services.workspace_service.BillingService.get_info", return_value=billing_info),
|
||||
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=trial_pool) as get_pool,
|
||||
):
|
||||
result = WorkspaceService.get_effective_credit_pool("tenant-1", session=session)
|
||||
|
||||
get_pool.assert_called_once_with(tenant_id="tenant-1", pool_type="trial", session=session)
|
||||
assert result.pool_type == "trial"
|
||||
assert result.remaining_credits == 0
|
||||
assert result.is_unlimited is False
|
||||
assert result.is_exhausted is True
|
||||
assert result.exhausted_at == 1772323200
|
||||
@@ -2891,14 +2891,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/hooks.ts": {
|
||||
"eslint-react/no-unnecessary-use-prefix": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
@@ -2923,14 +2915,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -2974,11 +2958,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -3368,14 +3347,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
@@ -227,6 +227,24 @@ export type ModelProviderListResponse = {
|
||||
data: Array<ProviderResponse>
|
||||
}
|
||||
|
||||
export type ModelProviderCreditsResponse = {
|
||||
exhausted_at: number | null
|
||||
is_exhausted: boolean
|
||||
is_unlimited: boolean
|
||||
next_credit_reset_date: number | null
|
||||
pool_type: 'paid' | 'trial' | null
|
||||
quota_limit: number | null
|
||||
quota_used: number | null
|
||||
remaining_credits: number | null
|
||||
}
|
||||
|
||||
export type ModelProviderSummaryListResponse = {
|
||||
data: Array<ModelProviderSummaryResponse>
|
||||
plugins: {
|
||||
[key: string]: ModelProviderPluginSummaryResponse
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelProviderPaymentCheckoutUrlResponse = {
|
||||
payment_link: string
|
||||
}
|
||||
@@ -417,6 +435,10 @@ export type ParserPluginIdentifiers = {
|
||||
plugin_unique_identifiers: Array<string>
|
||||
}
|
||||
|
||||
export type PluginInstalledIdsResponse = {
|
||||
plugin_ids: Array<string>
|
||||
}
|
||||
|
||||
export type PluginListResponse = {
|
||||
plugins: Array<PluginEntity>
|
||||
total: number
|
||||
@@ -1192,6 +1214,30 @@ export type ProviderResponse = {
|
||||
tenant_id: string
|
||||
}
|
||||
|
||||
export type ModelProviderSummaryResponse = {
|
||||
configurate_methods: Array<ConfigurateMethod>
|
||||
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
|
||||
description?: I18nObject | null
|
||||
icon_small?: I18nObject | null
|
||||
icon_small_dark?: I18nObject | null
|
||||
is_configured: boolean
|
||||
label: I18nObject
|
||||
plugin_id: string
|
||||
preferred_provider_type: ProviderType
|
||||
provider: string
|
||||
supported_model_types: Array<ModelType>
|
||||
system_configuration: ModelProviderSystemConfigurationSummaryResponse
|
||||
}
|
||||
|
||||
export type ModelProviderPluginSummaryResponse = {
|
||||
installation_id: string
|
||||
plugin_id: string
|
||||
plugin_unique_identifier: string
|
||||
runtime_type: string
|
||||
source: PluginInstallationSource
|
||||
version: string
|
||||
}
|
||||
|
||||
export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts'
|
||||
|
||||
export type ModelWithProviderEntityResponse = {
|
||||
@@ -1730,6 +1776,21 @@ export type SystemConfigurationResponse = {
|
||||
quota_configurations?: Array<QuotaConfiguration>
|
||||
}
|
||||
|
||||
export type ModelProviderCustomConfigurationSummaryResponse = {
|
||||
available_credentials: Array<CredentialConfiguration>
|
||||
current_credential_id?: string | null
|
||||
current_credential_name?: string | null
|
||||
current_credential_usable: boolean
|
||||
has_custom_models: boolean
|
||||
status: CustomConfigurationStatus
|
||||
}
|
||||
|
||||
export type ModelProviderSystemConfigurationSummaryResponse = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
|
||||
|
||||
export type ModelFeature =
|
||||
| 'agent-thought'
|
||||
| 'audio'
|
||||
@@ -1900,8 +1961,6 @@ export type PluginInstallTaskPluginStatus = {
|
||||
|
||||
export type PluginInstallTaskStatus = 'failed' | 'pending' | 'running' | 'success'
|
||||
|
||||
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
|
||||
|
||||
export type PluginDeclarationResponse = {
|
||||
agent_strategy?: {
|
||||
[key: string]: unknown
|
||||
@@ -3029,6 +3088,34 @@ export type GetWorkspacesCurrentModelProvidersResponses = {
|
||||
export type GetWorkspacesCurrentModelProvidersResponse =
|
||||
GetWorkspacesCurrentModelProvidersResponses[keyof GetWorkspacesCurrentModelProvidersResponses]
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersCreditsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/model-providers/credits'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersCreditsResponses = {
|
||||
200: ModelProviderCreditsResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersCreditsResponse =
|
||||
GetWorkspacesCurrentModelProvidersCreditsResponses[keyof GetWorkspacesCurrentModelProvidersCreditsResponses]
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersSummaryData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/model-providers/summary'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersSummaryResponses = {
|
||||
200: ModelProviderSummaryListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersSummaryResponse =
|
||||
GetWorkspacesCurrentModelProvidersSummaryResponses[keyof GetWorkspacesCurrentModelProvidersSummaryResponses]
|
||||
|
||||
export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlData = {
|
||||
body?: never
|
||||
path: {
|
||||
@@ -3575,6 +3662,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
|
||||
@@ -3888,8 +3991,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'
|
||||
}
|
||||
|
||||
@@ -158,6 +158,20 @@ export const zMemberRoleUpdatePayload = z.object({
|
||||
role: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelProviderCreditsResponse
|
||||
*/
|
||||
export const zModelProviderCreditsResponse = z.object({
|
||||
exhausted_at: z.int().nullable(),
|
||||
is_exhausted: z.boolean(),
|
||||
is_unlimited: z.boolean(),
|
||||
next_credit_reset_date: z.int().nullable(),
|
||||
pool_type: z.enum(['paid', 'trial']).nullable(),
|
||||
quota_limit: z.int().nullable(),
|
||||
quota_used: z.int().nullable(),
|
||||
remaining_credits: z.int().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelProviderPaymentCheckoutUrlResponse
|
||||
*/
|
||||
@@ -283,6 +297,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
|
||||
*/
|
||||
@@ -1613,6 +1634,30 @@ export const zConfigurateMethod = z.enum(['customizable-model', 'predefined-mode
|
||||
*/
|
||||
export const zProviderType = z.enum(['custom', 'system'])
|
||||
|
||||
/**
|
||||
* ModelProviderSystemConfigurationSummaryResponse
|
||||
*/
|
||||
export const zModelProviderSystemConfigurationSummaryResponse = z.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstallationSource
|
||||
*/
|
||||
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
|
||||
|
||||
/**
|
||||
* ModelProviderPluginSummaryResponse
|
||||
*/
|
||||
export const zModelProviderPluginSummaryResponse = z.object({
|
||||
installation_id: z.string(),
|
||||
plugin_id: z.string(),
|
||||
plugin_unique_identifier: z.string(),
|
||||
runtime_type: z.string(),
|
||||
source: zPluginInstallationSource,
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelFeature
|
||||
*
|
||||
@@ -1803,6 +1848,46 @@ export const zAvailableModelListResponse = z.object({
|
||||
data: z.array(zProviderWithModelsResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelProviderCustomConfigurationSummaryResponse
|
||||
*/
|
||||
export const zModelProviderCustomConfigurationSummaryResponse = z.object({
|
||||
available_credentials: z.array(zCredentialConfiguration),
|
||||
current_credential_id: z.string().nullish(),
|
||||
current_credential_name: z.string().nullish(),
|
||||
current_credential_usable: z.boolean(),
|
||||
has_custom_models: z.boolean(),
|
||||
status: zCustomConfigurationStatus,
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelProviderSummaryResponse
|
||||
*
|
||||
* Fields required to render the collapsed model-provider list.
|
||||
*/
|
||||
export const zModelProviderSummaryResponse = z.object({
|
||||
configurate_methods: z.array(zConfigurateMethod),
|
||||
custom_configuration: zModelProviderCustomConfigurationSummaryResponse,
|
||||
description: zI18nObject.nullish(),
|
||||
icon_small: zI18nObject.nullish(),
|
||||
icon_small_dark: zI18nObject.nullish(),
|
||||
is_configured: z.boolean(),
|
||||
label: zI18nObject,
|
||||
plugin_id: z.string(),
|
||||
preferred_provider_type: zProviderType,
|
||||
provider: z.string(),
|
||||
supported_model_types: z.array(zModelType),
|
||||
system_configuration: zModelProviderSystemConfigurationSummaryResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* ModelProviderSummaryListResponse
|
||||
*/
|
||||
export const zModelProviderSummaryListResponse = z.object({
|
||||
data: z.array(zModelProviderSummaryResponse),
|
||||
plugins: z.record(z.string(), zModelProviderPluginSummaryResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* TenantPluginAutoUpgradeStrategySetting
|
||||
*/
|
||||
@@ -1948,11 +2033,6 @@ export const zPluginTaskResponse = z.object({
|
||||
task: zPluginInstallTask,
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstallationSource
|
||||
*/
|
||||
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
|
||||
|
||||
/**
|
||||
* PluginBundleDependencyType
|
||||
*/
|
||||
@@ -3696,6 +3776,16 @@ export const zGetWorkspacesCurrentModelProvidersQuery = z.object({
|
||||
*/
|
||||
export const zGetWorkspacesCurrentModelProvidersResponse = zModelProviderListResponse
|
||||
|
||||
/**
|
||||
* Model provider credits retrieved successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentModelProvidersCreditsResponse = zModelProviderCreditsResponse
|
||||
|
||||
/**
|
||||
* Model provider summaries retrieved successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentModelProvidersSummaryResponse = zModelProviderSummaryListResponse
|
||||
|
||||
export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.object({
|
||||
provider: z.string(),
|
||||
})
|
||||
@@ -4071,6 +4161,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),
|
||||
@@ -4241,8 +4340,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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,12 @@ import { defaultPlan } from '@/app/components/billing/config'
|
||||
// Avoid being mocked in tests
|
||||
export const baseProviderContextValue: ProviderContextState = {
|
||||
modelProviders: [],
|
||||
modelProviderPlugins: {},
|
||||
refreshModelProviders: async () => {},
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
@@ -18,6 +21,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
onPlanInfoChanged: noop,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
datasetOperatorEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
isEducationWorkspace: false,
|
||||
isEducationAccount: false,
|
||||
@@ -45,7 +49,7 @@ export const createMockProviderContextValue = (
|
||||
|
||||
return {
|
||||
...merged,
|
||||
refreshModelProviders: merged.refreshModelProviders ?? (async () => {}),
|
||||
refreshModelProviders: merged.refreshModelProviders ?? noop,
|
||||
onPlanInfoChanged: merged.onPlanInfoChanged ?? noop,
|
||||
refreshLicenseLimit: merged.refreshLicenseLimit ?? noop,
|
||||
}
|
||||
|
||||
+8
-1
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelAndParameter } from '../../types'
|
||||
import type {
|
||||
@@ -158,7 +159,13 @@ describe('ModelParameterTrigger', () => {
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
modelProviders: [createModelProvider()],
|
||||
modelProviders: [
|
||||
{
|
||||
...createModelProvider(),
|
||||
is_configured: true,
|
||||
plugin_id: 'langgenius/openai',
|
||||
} as unknown as ModelProviderSummaryResponse,
|
||||
],
|
||||
}),
|
||||
)
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
|
||||
@@ -39,9 +39,12 @@ const mockUseProviderContext = actualUseProviderContext as MockedFunction<
|
||||
// Default mock data
|
||||
const defaultProviderContext = {
|
||||
modelProviders: [],
|
||||
modelProviderPlugins: {},
|
||||
refreshModelProviders: async () => {},
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
@@ -50,6 +53,7 @@ const defaultProviderContext = {
|
||||
onPlanInfoChanged: noop,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
datasetOperatorEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
isEducationWorkspace: false,
|
||||
isEducationAccount: false,
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ let mockModelProvidersData: {
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => mockCodeBasedExtensions,
|
||||
useModelProviders: () => mockModelProvidersData,
|
||||
useModelProviderDetails: () => mockModelProvidersData,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import { useDocLink, useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common'
|
||||
import { useCodeBasedExtensions, useModelProviderDetails } from '@/service/use-common'
|
||||
import FormGeneration from './form-generation'
|
||||
import ModerationContent from './moderation-content'
|
||||
|
||||
@@ -59,7 +59,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const locale = useLocale()
|
||||
const { data: modelProviders, isPending: isLoading } = useModelProviders()
|
||||
const { data: modelProviders, isPending: isLoading } = useModelProviderDetails()
|
||||
const localeDataRef = useRef<ModerationConfig>(data)
|
||||
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
|
||||
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
|
||||
|
||||
+4
-5
@@ -199,16 +199,15 @@ describe('Card Component', () => {
|
||||
// Act
|
||||
render(<Card item={mockItem} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Test Author/))!.not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/test-name/))!.not.toBeInTheDocument()
|
||||
expect(screen.getByText('1.2.0'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Test Label' }))!.toHaveAttribute(
|
||||
'src',
|
||||
'test-icon-url',
|
||||
)
|
||||
const icon = screen.getByRole('img', { name: 'Test Label' })
|
||||
expect(icon).toHaveAttribute('src', 'test-icon-url')
|
||||
expect(icon).toHaveAttribute('loading', 'lazy')
|
||||
expect(icon).toHaveAttribute('decoding', 'async')
|
||||
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/plugin.auth.default/))!.toBeInTheDocument()
|
||||
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import { fireEvent, screen } from '@testing-library/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import {
|
||||
useGetDataSourceListAuth,
|
||||
@@ -301,6 +302,9 @@ describe('DataSourcePage Component', () => {
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-actions-plugin-1')).toBeInTheDocument()
|
||||
expect(useInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter installed data sources and pass search text to marketplace', () => {
|
||||
|
||||
@@ -108,6 +108,8 @@ const Card = ({ item, disabled, pluginDetail, onPluginUpdate }: CardProps) => {
|
||||
alt={providerLabel}
|
||||
width={20}
|
||||
height={20}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,9 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource
|
||||
select: (s) => s.enable_marketplace,
|
||||
})
|
||||
const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth()
|
||||
const { data: installedPluginList } = useInstalledPluginList()
|
||||
const { data: installedPluginList } = useInstalledPluginList({
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
const pluginListWithLatestVersion = usePluginsWithLatestVersion(installedPluginList?.plugins)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
|
||||
|
||||
+83
-64
@@ -21,7 +21,7 @@ import {
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../declarations'
|
||||
import {
|
||||
useCurrentProviderAndModel,
|
||||
getCurrentProviderAndModel,
|
||||
useDefaultModel,
|
||||
useInvalidateDefaultModel,
|
||||
useLanguage,
|
||||
@@ -58,6 +58,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
commonQueryKeys: {
|
||||
modelList: (type: string) => ['model-list', type],
|
||||
modelProviders: ['model-providers'],
|
||||
modelProviderDetails: ['model-provider-details'],
|
||||
defaultModel: (type: string) => ['default-model', type],
|
||||
},
|
||||
}))
|
||||
@@ -293,6 +294,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,
|
||||
@@ -411,7 +429,7 @@ describe('hooks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCurrentProviderAndModel', () => {
|
||||
describe('getCurrentProviderAndModel', () => {
|
||||
const createModelList = (): Model[] => [
|
||||
{
|
||||
provider: 'openai',
|
||||
@@ -445,7 +463,7 @@ describe('hooks', () => {
|
||||
const modelList = createModelList()
|
||||
const defaultModel = { provider: 'openai', model: 'gpt-4' }
|
||||
|
||||
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
|
||||
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
|
||||
|
||||
expect(result.current.currentProvider?.provider).toBe('openai')
|
||||
expect(result.current.currentModel?.model).toBe('gpt-4')
|
||||
@@ -455,7 +473,7 @@ describe('hooks', () => {
|
||||
const modelList = createModelList()
|
||||
const defaultModel = { provider: 'anthropic', model: 'claude-3' }
|
||||
|
||||
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
|
||||
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
|
||||
|
||||
expect(result.current.currentProvider).toBeUndefined()
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
@@ -465,7 +483,7 @@ describe('hooks', () => {
|
||||
const modelList = createModelList()
|
||||
const defaultModel = { provider: 'openai', model: 'gpt-5' }
|
||||
|
||||
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, defaultModel))
|
||||
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, defaultModel))
|
||||
|
||||
expect(result.current.currentProvider?.provider).toBe('openai')
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
@@ -474,7 +492,7 @@ describe('hooks', () => {
|
||||
it('should handle undefined default model', () => {
|
||||
const modelList = createModelList()
|
||||
|
||||
const { result } = renderHook(() => useCurrentProviderAndModel(modelList, undefined))
|
||||
const { result } = renderHook(() => getCurrentProviderAndModel(modelList, undefined))
|
||||
|
||||
expect(result.current.currentProvider).toBeUndefined()
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
@@ -483,7 +501,7 @@ describe('hooks', () => {
|
||||
it('should handle empty model list', () => {
|
||||
const defaultModel = { provider: 'openai', model: 'gpt-4' }
|
||||
|
||||
const { result } = renderHook(() => useCurrentProviderAndModel([], defaultModel))
|
||||
const { result } = renderHook(() => getCurrentProviderAndModel([], defaultModel))
|
||||
|
||||
expect(result.current.currentProvider).toBeUndefined()
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
@@ -754,7 +772,10 @@ describe('hooks', () => {
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-providers'],
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-provider-details'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -770,55 +791,17 @@ describe('hooks', () => {
|
||||
result.current()
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(3)
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMarketplaceAllPlugins', () => {
|
||||
const createMockProviders = (): ModelProvider[] => [
|
||||
{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: { credential_form_schemas: [] },
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: '模型' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: '选择模型' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||
quota_configurations: [],
|
||||
},
|
||||
help: {
|
||||
title: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
url: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const createMockPlugins = () => [
|
||||
{ plugin_id: 'plugin1', type: 'plugin' },
|
||||
{ plugin_id: 'plugin2', type: 'plugin' },
|
||||
]
|
||||
|
||||
it('should combine collection and regular plugins', () => {
|
||||
const providers = createMockProviders()
|
||||
const collectionPlugins = [{ plugin_id: 'collection1', type: 'plugin' }]
|
||||
const regularPlugins = createMockPlugins()
|
||||
;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
@@ -832,14 +815,13 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(3)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should exclude installed providers', () => {
|
||||
const providers = createMockProviders()
|
||||
const collectionPlugins = [
|
||||
{ plugin_id: 'openai', type: 'plugin' },
|
||||
{ plugin_id: 'other', type: 'plugin' },
|
||||
@@ -849,16 +831,22 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
;(useMarketplacePlugins as Mock).mockReturnValue({
|
||||
plugins: [],
|
||||
plugins: [
|
||||
{ plugin_id: 'openai', type: 'plugin' },
|
||||
{ plugin_id: 'regular-only', type: 'plugin' },
|
||||
],
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', ['openai']))
|
||||
|
||||
expect(result.current.plugins!).toHaveLength(1)
|
||||
expect(result.current.plugins![0]!.plugin_id).toBe('other')
|
||||
expect(result.current.plugins!).toHaveLength(2)
|
||||
expect(result.current.plugins!.map((plugin) => plugin.plugin_id)).toEqual([
|
||||
'other',
|
||||
'regular-only',
|
||||
])
|
||||
})
|
||||
|
||||
it('should use search when searchText is provided', () => {
|
||||
@@ -874,7 +862,7 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplaceAllPlugins([], 'test search'))
|
||||
renderHook(() => useMarketplaceAllPlugins('test search', []))
|
||||
|
||||
expect(queryPluginsWithDebounced).toHaveBeenCalled()
|
||||
})
|
||||
@@ -895,7 +883,7 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
|
||||
|
||||
expect(result.current.plugins!).toHaveLength(1)
|
||||
expect(result.current.plugins![0]!.plugin_id).toBe('plugin1')
|
||||
@@ -914,7 +902,7 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(2)
|
||||
expect(result.current.plugins!.filter((p) => p.plugin_id === 'shared-plugin')).toHaveLength(1)
|
||||
@@ -932,7 +920,7 @@ describe('hooks', () => {
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
@@ -949,7 +937,7 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', []))
|
||||
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
@@ -969,13 +957,33 @@ describe('hooks', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('openai', []))
|
||||
|
||||
expect(result.current.plugins).toEqual(searchPlugins)
|
||||
expect(result.current.plugins?.some((p) => p.plugin_id === 'collection-only')).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip marketplace queries when disabled', () => {
|
||||
it('should hide installed plugins when a search response is stale', () => {
|
||||
;(useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
})
|
||||
;(useMarketplacePlugins as Mock).mockReturnValue({
|
||||
plugins: [
|
||||
{ plugin_id: 'langgenius/openai', type: 'plugin' },
|
||||
{ plugin_id: 'langgenius/other', type: 'plugin' },
|
||||
],
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('openai', ['langgenius/openai']))
|
||||
|
||||
expect(result.current.plugins).toEqual([{ plugin_id: 'langgenius/other', type: 'plugin' }])
|
||||
})
|
||||
|
||||
it('should preserve marketplace cache when disabled', () => {
|
||||
const queryPlugins = vi.fn()
|
||||
const queryPluginsWithDebounced = vi.fn()
|
||||
const cancelQueryPluginsWithDebounced = vi.fn()
|
||||
@@ -993,13 +1001,14 @@ describe('hooks', () => {
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], '', false))
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins('', [], false))
|
||||
|
||||
expect(useMarketplacePluginsByCollectionId).toHaveBeenCalledWith(undefined)
|
||||
expect(queryPlugins).not.toHaveBeenCalled()
|
||||
expect(queryPluginsWithDebounced).not.toHaveBeenCalled()
|
||||
expect(cancelQueryPluginsWithDebounced).toHaveBeenCalled()
|
||||
expect(resetPlugins).toHaveBeenCalled()
|
||||
expect(resetPlugins).not.toHaveBeenCalled()
|
||||
expect(useMarketplacePlugins).toHaveBeenCalledWith(false)
|
||||
expect(result.current.plugins).toEqual([])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
@@ -1065,7 +1074,12 @@ describe('hooks', () => {
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-provider-details'],
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-list', ModelTypeEnum.textGeneration],
|
||||
})
|
||||
@@ -1208,7 +1222,12 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-provider-details'],
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-list', ModelTypeEnum.textGeneration],
|
||||
})
|
||||
|
||||
+93
-64
@@ -49,6 +49,18 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
|
||||
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
|
||||
mockProviderContextState: {
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: true,
|
||||
modelProviderPlugins: {} as Record<
|
||||
string,
|
||||
{
|
||||
installation_id: string
|
||||
plugin_id: string
|
||||
plugin_unique_identifier: string
|
||||
runtime_type: string
|
||||
source: 'github' | 'marketplace' | 'package' | 'remote'
|
||||
version: string
|
||||
}
|
||||
>,
|
||||
},
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
}))
|
||||
@@ -159,9 +171,22 @@ const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail
|
||||
}
|
||||
}
|
||||
|
||||
const mockProviders = [
|
||||
type MockProvider = {
|
||||
provider: string
|
||||
plugin_id?: string
|
||||
label: { en_US: string }
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum }
|
||||
system_configuration: {
|
||||
enabled: boolean
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum
|
||||
quota_configurations: (typeof mockQuotaConfig)[]
|
||||
}
|
||||
}
|
||||
|
||||
const mockProviders: MockProvider[] = [
|
||||
{
|
||||
provider: 'openai',
|
||||
plugin_id: 'langgenius/openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
@@ -172,6 +197,7 @@ const mockProviders = [
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
plugin_id: 'langgenius/anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
@@ -184,8 +210,15 @@ const mockProviders = [
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: mockProviders,
|
||||
modelProviders: mockProviders.map((provider) => ({
|
||||
...provider,
|
||||
is_configured:
|
||||
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
|
||||
provider.system_configuration.enabled,
|
||||
})),
|
||||
modelProviderPlugins: mockProviderContextState.modelProviderPlugins,
|
||||
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
|
||||
isSuccessModelProviders: mockProviderContextState.isSuccessModelProviders,
|
||||
refreshModelProviders: mockRefreshModelProviders,
|
||||
}),
|
||||
}))
|
||||
@@ -211,17 +244,17 @@ vi.mock('../provider-added-card', () => ({
|
||||
default: ({
|
||||
notConfigured,
|
||||
provider,
|
||||
pluginDetail,
|
||||
pluginSummary,
|
||||
}: {
|
||||
notConfigured?: boolean
|
||||
provider: { provider: string }
|
||||
pluginDetail?: { plugin_id: string; source?: string }
|
||||
pluginSummary?: { plugin_id: string; source?: string }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="provider-card"
|
||||
data-not-configured={String(!!notConfigured)}
|
||||
data-plugin-id={pluginDetail?.plugin_id ?? ''}
|
||||
data-plugin-source={pluginDetail?.source ?? ''}
|
||||
data-plugin-id={pluginSummary?.plugin_id ?? ''}
|
||||
data-plugin-source={pluginSummary?.source ?? ''}
|
||||
>
|
||||
{provider.provider}
|
||||
</div>
|
||||
@@ -355,6 +388,8 @@ describe('ModelProviderPage', () => {
|
||||
mockRefreshModelProviders.mockClear()
|
||||
mockInstalledModelPlugins.value = []
|
||||
mockProviderContextState.isLoadingModelProviders = false
|
||||
mockProviderContextState.isSuccessModelProviders = true
|
||||
mockProviderContextState.modelProviderPlugins = {}
|
||||
mockAutoUpgradeError.value = undefined
|
||||
mockReferenceSetting.auto_upgrade = {
|
||||
strategy_setting: 'latest',
|
||||
@@ -371,6 +406,7 @@ describe('ModelProviderPage', () => {
|
||||
mockProviders.length,
|
||||
{
|
||||
provider: 'openai',
|
||||
plugin_id: 'langgenius/openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
@@ -381,6 +417,7 @@ describe('ModelProviderPage', () => {
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
plugin_id: 'langgenius/anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
@@ -531,9 +568,10 @@ describe('ModelProviderPage', () => {
|
||||
expect(target).toContainElement(screen.getByText('common.modelProvider.emptyProviderTitle'))
|
||||
})
|
||||
|
||||
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
|
||||
it('should use the summary plugin map to attach plugin metadata to provider cards', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'langgenius/openai/openai',
|
||||
plugin_id: 'langgenius/openai-marketplace',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
@@ -542,25 +580,23 @@ describe('ModelProviderPage', () => {
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
mockInstalledModelPlugins.value = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'langgenius/openai',
|
||||
declaration: createPluginDeclaration({
|
||||
plugin_unique_identifier: 'langgenius/openai:1.0.0',
|
||||
name: 'openai',
|
||||
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
|
||||
}),
|
||||
}),
|
||||
]
|
||||
mockProviderContextState.modelProviderPlugins = {
|
||||
'langgenius/openai-marketplace': {
|
||||
installation_id: 'openai-installation',
|
||||
plugin_id: 'langgenius/openai-marketplace',
|
||||
plugin_unique_identifier: 'langgenius/openai:1.0.0',
|
||||
runtime_type: 'local',
|
||||
source: 'marketplace',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
|
||||
category: PluginCategoryEnum.model,
|
||||
})
|
||||
expect(mockUseInstalledPluginList).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('provider-card')).toHaveAttribute(
|
||||
'data-plugin-id',
|
||||
'langgenius/openai',
|
||||
'langgenius/openai-marketplace',
|
||||
)
|
||||
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -587,24 +623,27 @@ describe('ModelProviderPage', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
|
||||
mockInstalledModelPlugins.value = [
|
||||
createPluginDetail({
|
||||
it('should not refresh providers when remote plugin metadata already comes from summary', () => {
|
||||
mockProviderContextState.modelProviderPlugins = {
|
||||
'langgenius/debug-model': {
|
||||
installation_id: 'debug-installation',
|
||||
plugin_id: 'langgenius/debug-model',
|
||||
declaration: createPluginDeclaration({
|
||||
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||
}),
|
||||
}),
|
||||
]
|
||||
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
|
||||
runtime_type: 'remote',
|
||||
source: 'remote',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
|
||||
it('should render remote source from the authoritative summary plugin entry', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'langgenius/openai/openai',
|
||||
plugin_id: 'langgenius/openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
@@ -613,26 +652,16 @@ describe('ModelProviderPage', () => {
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
mockInstalledModelPlugins.value = [
|
||||
createPluginDetail({
|
||||
mockProviderContextState.modelProviderPlugins = {
|
||||
'langgenius/openai': {
|
||||
installation_id: 'openai-debug-installation',
|
||||
plugin_id: 'langgenius/openai',
|
||||
declaration: createPluginDeclaration({
|
||||
plugin_unique_identifier: 'langgenius/openai:debug',
|
||||
name: 'openai',
|
||||
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
|
||||
}),
|
||||
source: PluginSource.debugging,
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'langgenius/openai',
|
||||
declaration: createPluginDeclaration({
|
||||
plugin_unique_identifier: 'langgenius/openai:1.0.0',
|
||||
name: 'openai',
|
||||
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
|
||||
}),
|
||||
source: PluginSource.marketplace,
|
||||
}),
|
||||
]
|
||||
plugin_unique_identifier: 'langgenius/openai:debug',
|
||||
runtime_type: 'remote',
|
||||
source: 'remote',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
@@ -640,18 +669,17 @@ describe('ModelProviderPage', () => {
|
||||
'data-plugin-id',
|
||||
'langgenius/openai',
|
||||
)
|
||||
expect(screen.getByTestId('provider-card')).toHaveAttribute(
|
||||
'data-plugin-source',
|
||||
PluginSource.debugging,
|
||||
)
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', 'remote')
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show provider placeholders while model providers are loading', () => {
|
||||
mockProviderContextState.isLoadingModelProviders = true
|
||||
mockProviderContextState.isSuccessModelProviders = false
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockUseInstalledPluginList).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('provider-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
@@ -857,6 +885,7 @@ describe('ModelProviderPage', () => {
|
||||
},
|
||||
{
|
||||
provider: 'langgenius/debug-model/debug-model',
|
||||
plugin_id: 'langgenius/debug-model',
|
||||
label: { en_US: 'Debug Model' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
@@ -866,16 +895,16 @@ describe('ModelProviderPage', () => {
|
||||
},
|
||||
},
|
||||
)
|
||||
mockInstalledModelPlugins.value = [
|
||||
createPluginDetail({
|
||||
mockProviderContextState.modelProviderPlugins = {
|
||||
'langgenius/debug-model': {
|
||||
installation_id: 'debug-installation',
|
||||
plugin_id: 'langgenius/debug-model',
|
||||
declaration: createPluginDeclaration({
|
||||
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
|
||||
name: 'debug-model',
|
||||
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||
}),
|
||||
}),
|
||||
]
|
||||
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
|
||||
runtime_type: 'remote',
|
||||
source: 'remote',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
|
||||
+57
-19
@@ -1,14 +1,26 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getStepByStepTourTargetSelector,
|
||||
STEP_BY_STEP_TOUR_TARGETS,
|
||||
} from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import InstallFromMarketplace from '../install-from-marketplace'
|
||||
|
||||
const render = (ui: React.ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.workspaces.current.plugin.installedIds.get.queryKey({
|
||||
input: { query: { category: 'model' } },
|
||||
}),
|
||||
{ plugin_ids: [] },
|
||||
)
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
@@ -76,20 +88,53 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
}))
|
||||
|
||||
describe('InstallFromMarketplace', () => {
|
||||
const mockProviders = [] as ModelProvider[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should wait until the section approaches the viewport before loading marketplace data', () => {
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined
|
||||
const disconnect = vi.fn()
|
||||
function MockIntersectionObserver(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback
|
||||
return {
|
||||
disconnect,
|
||||
observe: vi.fn(),
|
||||
takeRecords: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
root: null,
|
||||
thresholds: [0],
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', vi.fn(MockIntersectionObserver))
|
||||
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
|
||||
expect(useMarketplaceAllPlugins).toHaveBeenLastCalledWith('', [], false)
|
||||
|
||||
act(() => {
|
||||
intersectionCallback?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
expect(useMarketplaceAllPlugins).toHaveBeenLastCalledWith('', [], true)
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render expanded by default', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
expect(screen.getByText('common.modelProvider.installProvider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse when clicked', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
@@ -107,7 +152,7 @@ describe('InstallFromMarketplace', () => {
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
// It's expanded by default, so loading should show immediately
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||
})
|
||||
@@ -118,7 +163,7 @@ describe('InstallFromMarketplace', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
// Expanded by default
|
||||
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
|
||||
})
|
||||
@@ -136,7 +181,6 @@ describe('InstallFromMarketplace', () => {
|
||||
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
providers={mockProviders}
|
||||
searchText=""
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
|
||||
/>,
|
||||
@@ -164,14 +208,14 @@ describe('InstallFromMarketplace', () => {
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
|
||||
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Bundle 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render discovery link', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
render(<InstallFromMarketplace searchText="" />)
|
||||
expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.test/plugins/model?theme=light',
|
||||
@@ -181,13 +225,7 @@ describe('InstallFromMarketplace', () => {
|
||||
it('should use the marketplace callback action when provided', () => {
|
||||
const onOpenMarketplace = vi.fn()
|
||||
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
providers={mockProviders}
|
||||
searchText=""
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>,
|
||||
)
|
||||
render(<InstallFromMarketplace searchText="" onOpenMarketplace={onOpenMarketplace} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
genModelNameFormSchema,
|
||||
genModelTypeFormSchema,
|
||||
modelTypeFormat,
|
||||
providerToPluginId,
|
||||
sizeFormat,
|
||||
} from '../utils'
|
||||
|
||||
@@ -23,16 +22,6 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerToPluginId', () => {
|
||||
it('should return the plugin id prefix when the provider key contains a provider segment', () => {
|
||||
expect(providerToPluginId('langgenius/openai/openai')).toBe('langgenius/openai')
|
||||
})
|
||||
|
||||
it('should return an empty string when the provider key has no plugin prefix', () => {
|
||||
expect(providerToPluginId('openai')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelTypeFormat', () => {
|
||||
it('should format text embedding type', () => {
|
||||
expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING')
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { Model, ModelItem, ModelProvider } from './declarations'
|
||||
import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state'
|
||||
import { ModelStatusEnum } from './declarations'
|
||||
@@ -28,7 +29,7 @@ export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = {
|
||||
export const deriveModelStatus = (
|
||||
modelId: string | undefined,
|
||||
providerName: string | undefined,
|
||||
currentModelProvider: ModelProvider | Model | undefined,
|
||||
currentModelProvider: ModelProvider | ModelProviderSummaryResponse | Model | undefined,
|
||||
currentModel: ModelItem | undefined,
|
||||
credentialState: CredentialPanelState,
|
||||
): DerivedModelStatus => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
ConfigurationMethodEnum,
|
||||
Credential,
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
ModelProvider,
|
||||
ModelTypeEnum,
|
||||
} from './declarations'
|
||||
import type { ModelModalType } from '@/context/modal-context'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
@@ -22,7 +24,7 @@ import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchDefaultModal, fetchModelList } from '@/service/common'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { commonQueryKeys, modelProviderDetailsQueryOptions } from '@/service/use-common'
|
||||
import { useExpandModelProviderList } from './atoms'
|
||||
import { CustomConfigurationStatusEnum, ModelStatusEnum } from './declarations'
|
||||
|
||||
@@ -74,10 +76,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 {
|
||||
@@ -100,7 +108,7 @@ export const useDefaultModel = (type: ModelTypeEnum) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => {
|
||||
export const getCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => {
|
||||
const currentProvider = modelList.find((provider) => provider.provider === defaultModel?.provider)
|
||||
const currentModel = currentProvider?.models.find((model) => model.model === defaultModel?.model)
|
||||
|
||||
@@ -110,6 +118,8 @@ export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: De
|
||||
}
|
||||
}
|
||||
|
||||
export { getCurrentProviderAndModel as useCurrentProviderAndModel }
|
||||
|
||||
export const useTextGenerationCurrentProviderAndModelAndModelList = (
|
||||
defaultModel?: DefaultModel,
|
||||
) => {
|
||||
@@ -117,7 +127,7 @@ export const useTextGenerationCurrentProviderAndModelAndModelList = (
|
||||
const activeTextGenerationModelList = textGenerationModelList.filter(
|
||||
(model) => model.status === ModelStatusEnum.active,
|
||||
)
|
||||
const { currentProvider, currentModel } = useCurrentProviderAndModel(
|
||||
const { currentProvider, currentModel } = getCurrentProviderAndModel(
|
||||
textGenerationModelList,
|
||||
defaultModel,
|
||||
)
|
||||
@@ -142,7 +152,7 @@ export const useModelListAndDefaultModel = (type: ModelTypeEnum) => {
|
||||
|
||||
export const useModelListAndDefaultModelAndCurrentProviderAndModel = (type: ModelTypeEnum) => {
|
||||
const { modelList, defaultModel } = useModelListAndDefaultModel(type)
|
||||
const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, {
|
||||
const { currentProvider, currentModel } = getCurrentProviderAndModel(modelList, {
|
||||
provider: defaultModel?.provider.provider || '',
|
||||
model: defaultModel?.model || '',
|
||||
})
|
||||
@@ -159,7 +169,7 @@ export const useUpdateModelList = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const updateModelList = useCallback(
|
||||
(type: ModelTypeEnum) => {
|
||||
(type: ModelTypeEnum | ModelType) => {
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelList(type) })
|
||||
},
|
||||
[queryClient],
|
||||
@@ -182,20 +192,48 @@ export const useUpdateModelProviders = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const updateModelProviders = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviders })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails })
|
||||
}, [queryClient])
|
||||
|
||||
return updateModelProviders
|
||||
}
|
||||
|
||||
export const useLazyModelProviderDetail = (providerName: string) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isFetching } = useQuery({
|
||||
...modelProviderDetailsQueryOptions(),
|
||||
enabled,
|
||||
})
|
||||
const providerDetail = data?.data.find((provider) => provider.provider === providerName)
|
||||
|
||||
const loadProviderDetail = useCallback(async () => {
|
||||
setEnabled(true)
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(modelProviderDetailsQueryOptions())
|
||||
return response.data.find((provider) => provider.provider === providerName)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}, [providerName, queryClient])
|
||||
|
||||
return {
|
||||
providerDetail,
|
||||
loadProviderDetail,
|
||||
isProviderDetailEnabled: enabled,
|
||||
isLoadingProviderDetail: enabled && isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplaceAllPlugins = (
|
||||
providers: ModelProvider[],
|
||||
searchText: string,
|
||||
installedPluginIds: string[],
|
||||
enabled = true,
|
||||
) => {
|
||||
const exclude = useMemo(() => {
|
||||
return providers.map((provider) => provider.provider.replace(/(.+)\/([^/]+)$/, '$1'))
|
||||
}, [providers])
|
||||
const exclude = installedPluginIds
|
||||
const { plugins: collectionPlugins = [], isLoading: isCollectionLoading } =
|
||||
useMarketplacePluginsByCollectionId(enabled ? '__model-settings-pinned-models' : undefined)
|
||||
const {
|
||||
@@ -203,14 +241,12 @@ export const useMarketplaceAllPlugins = (
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced = () => {},
|
||||
resetPlugins = () => {},
|
||||
isLoading: isPluginsLoading,
|
||||
} = useMarketplacePlugins()
|
||||
} = useMarketplacePlugins(enabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
cancelQueryPluginsWithDebounced()
|
||||
resetPlugins()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,7 +275,6 @@ export const useMarketplaceAllPlugins = (
|
||||
enabled,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
resetPlugins,
|
||||
searchText,
|
||||
exclude,
|
||||
])
|
||||
@@ -253,7 +288,11 @@ export const useMarketplaceAllPlugins = (
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i]
|
||||
|
||||
if (plugin!.type !== 'bundle' && !allPlugins.find((p) => p.plugin_id === plugin!.plugin_id))
|
||||
if (
|
||||
!exclude.includes(plugin!.plugin_id) &&
|
||||
plugin!.type !== 'bundle' &&
|
||||
!allPlugins.find((p) => p.plugin_id === plugin!.plugin_id)
|
||||
)
|
||||
allPlugins.push(plugin!)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +301,10 @@ export const useMarketplaceAllPlugins = (
|
||||
}, [enabled, plugins, collectionPlugins, exclude])
|
||||
|
||||
return {
|
||||
plugins: enabled && searchText ? plugins : allPlugins,
|
||||
plugins:
|
||||
enabled && searchText
|
||||
? plugins?.filter((plugin) => !exclude.includes(plugin.plugin_id))
|
||||
: allPlugins,
|
||||
isLoading: enabled && (isCollectionLoading || isPluginsLoading),
|
||||
}
|
||||
}
|
||||
@@ -332,7 +374,7 @@ export const useModelModalHandler = () => {
|
||||
isModelCredential?: boolean
|
||||
credential?: Credential
|
||||
model?: CustomModel
|
||||
onUpdate?: (newPayload: any, formValues?: Record<string, any>) => void
|
||||
onUpdate?: (newPayload?: ModelModalType, formValues?: Record<string, unknown>) => void
|
||||
mode?: ModelModalModeEnum
|
||||
} = {},
|
||||
) => {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type {
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider } from './declarations'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import UpdateSettingDialog from '../update-setting-dialog'
|
||||
import { CustomConfigurationStatusEnum, ModelTypeEnum } from './declarations'
|
||||
import { ModelTypeEnum } from './declarations'
|
||||
import { useDefaultModel } from './hooks'
|
||||
import ModelProviderPageBody from './model-provider-page-body'
|
||||
import SystemModelSelector from './system-model-selector'
|
||||
@@ -36,6 +37,11 @@ type Props = Readonly<{
|
||||
|
||||
const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
|
||||
|
||||
export type ModelProviderPluginSummary = ModelProviderPluginSummaryResponse & {
|
||||
latestVersion?: string
|
||||
latestUniqueIdentifier?: string
|
||||
}
|
||||
|
||||
const ModelProviderPage = ({
|
||||
layout,
|
||||
onOpenMarketplace,
|
||||
@@ -61,44 +67,36 @@ const ModelProviderPage = ({
|
||||
)
|
||||
const {
|
||||
modelProviders: providers,
|
||||
modelProviderPlugins = {},
|
||||
isLoadingModelProviders,
|
||||
refreshModelProviders,
|
||||
} = useProviderContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
|
||||
category: PluginCategoryEnum.model,
|
||||
})
|
||||
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
|
||||
const pluginDetailMap = useMemo(() => {
|
||||
const map = new Map<string, PluginDetail>()
|
||||
for (const plugin of enrichedPlugins) {
|
||||
const existingPlugin = map.get(plugin.plugin_id)
|
||||
if (!existingPlugin || plugin.source === PluginSource.debugging)
|
||||
map.set(plugin.plugin_id, plugin)
|
||||
const marketplacePluginIds = useMemo(
|
||||
() =>
|
||||
Object.values(modelProviderPlugins)
|
||||
.filter((plugin) => plugin.source === 'marketplace')
|
||||
.map((plugin) => plugin.plugin_id),
|
||||
[modelProviderPlugins],
|
||||
)
|
||||
const { data: latestVersionData } = useQuery(
|
||||
consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({
|
||||
input: { body: { plugin_ids: marketplacePluginIds } },
|
||||
enabled: !!marketplacePluginIds.length,
|
||||
}),
|
||||
)
|
||||
const pluginSummaryMap = useMemo(() => {
|
||||
const map = new Map<string, ModelProviderPluginSummary>()
|
||||
for (const plugin of Object.values(modelProviderPlugins)) {
|
||||
const latestVersion = latestVersionData?.versions[plugin.plugin_id]
|
||||
map.set(plugin.plugin_id, {
|
||||
...plugin,
|
||||
latestVersion: latestVersion?.version,
|
||||
latestUniqueIdentifier: latestVersion?.unique_identifier,
|
||||
})
|
||||
}
|
||||
return map
|
||||
}, [enrichedPlugins])
|
||||
const debuggingModelPluginKey = useMemo(() => {
|
||||
const debuggingModelPluginIds = enrichedPlugins
|
||||
.filter((plugin) => plugin.source === PluginSource.debugging)
|
||||
.map((plugin) => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
|
||||
.sort()
|
||||
|
||||
return debuggingModelPluginIds.join(',')
|
||||
}, [enrichedPlugins])
|
||||
const refreshedDebuggingModelPluginKeyRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (!debuggingModelPluginKey) {
|
||||
refreshedDebuggingModelPluginKeyRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey) return
|
||||
|
||||
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
|
||||
refreshModelProviders?.()
|
||||
}, [debuggingModelPluginKey, refreshModelProviders])
|
||||
}, [latestVersionData, modelProviderPlugins])
|
||||
const enableMarketplace = systemFeatures.enable_marketplace
|
||||
const isDefaultModelLoading =
|
||||
isTextGenerationDefaultModelLoading ||
|
||||
@@ -107,17 +105,11 @@ const ModelProviderPage = ({
|
||||
isSpeech2textDefaultModelLoading ||
|
||||
isTTSDefaultModelLoading
|
||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||
const configuredProviders: ModelProvider[] = []
|
||||
const notConfiguredProviders: ModelProvider[] = []
|
||||
const configuredProviders: ModelProviderSummaryResponse[] = []
|
||||
const notConfiguredProviders: ModelProviderSummaryResponse[] = []
|
||||
|
||||
providers.forEach((provider) => {
|
||||
if (
|
||||
provider.custom_configuration.status === CustomConfigurationStatusEnum.active ||
|
||||
(provider.system_configuration.enabled === true &&
|
||||
provider.system_configuration.quota_configurations.some(
|
||||
(item) => item.quota_type === provider.system_configuration.current_quota_type,
|
||||
))
|
||||
) {
|
||||
if (provider.is_configured) {
|
||||
configuredProviders.push(provider)
|
||||
} else {
|
||||
notConfiguredProviders.push(provider)
|
||||
@@ -183,14 +175,14 @@ const ModelProviderPage = ({
|
||||
(provider) =>
|
||||
provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) ||
|
||||
Object.values(provider.label).some((text) =>
|
||||
text.toLowerCase().includes(debouncedSearchText.toLowerCase()),
|
||||
text?.toLowerCase().includes(debouncedSearchText.toLowerCase()),
|
||||
),
|
||||
)
|
||||
const filteredNotConfiguredProviders = notConfiguredProviders.filter(
|
||||
(provider) =>
|
||||
provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) ||
|
||||
Object.values(provider.label).some((text) =>
|
||||
text.toLowerCase().includes(debouncedSearchText.toLowerCase()),
|
||||
text?.toLowerCase().includes(debouncedSearchText.toLowerCase()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -257,7 +249,7 @@ const ModelProviderPage = ({
|
||||
showMarketplace={showMarketplace}
|
||||
enableMarketplace={enableMarketplace}
|
||||
searchText={searchText}
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
pluginSummaryMap={pluginSummaryMap}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
|
||||
+51
-11
@@ -1,8 +1,8 @@
|
||||
import type { ModelProvider } from './declarations'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@@ -12,17 +12,16 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMarketplaceAllPlugins } from './hooks'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
onOpenMarketplace?: () => void
|
||||
providers: ModelProvider[]
|
||||
searchText: string
|
||||
stepByStepTourTarget?: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
onOpenMarketplace,
|
||||
providers,
|
||||
searchText,
|
||||
stepByStepTourTarget,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
@@ -30,10 +29,44 @@ const InstallFromMarketplace = ({
|
||||
const { theme } = useTheme()
|
||||
const { canInstallPlugin } = usePluginSettingsAccess()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins(
|
||||
providers,
|
||||
searchText,
|
||||
const [hasEnteredViewport, setHasEnteredViewport] = useState(
|
||||
() => !globalThis.IntersectionObserver,
|
||||
)
|
||||
const [hasBeenReopened, setHasBeenReopened] = useState(false)
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const shouldLoadMarketplace = !collapse && (hasEnteredViewport || !!searchText || hasBeenReopened)
|
||||
const { data: installedPluginIds, isSuccess: hasLoadedInstalledPluginIds } = useQuery({
|
||||
...consoleQuery.workspaces.current.plugin.installedIds.get.queryOptions({
|
||||
input: { query: { category: 'model' } },
|
||||
enabled: shouldLoadMarketplace,
|
||||
}),
|
||||
select: (data) => data.plugin_ids,
|
||||
})
|
||||
const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins(
|
||||
searchText,
|
||||
installedPluginIds ?? [],
|
||||
shouldLoadMarketplace && hasLoadedInstalledPluginIds,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current
|
||||
if (!section || hasEnteredViewport) return
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (!entry?.isIntersecting) return
|
||||
setHasEnteredViewport(true)
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(section)
|
||||
return () => observer.disconnect()
|
||||
}, [hasEnteredViewport])
|
||||
|
||||
const handleToggle = () => {
|
||||
setCollapse((previous) => {
|
||||
if (previous) setHasBeenReopened(true)
|
||||
return !previous
|
||||
})
|
||||
}
|
||||
|
||||
const cardRender = useCallback((plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle') return null
|
||||
@@ -42,7 +75,11 @@ const InstallFromMarketplace = ({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="model-provider-marketplace" className="flex scroll-mt-4 flex-col gap-2">
|
||||
<div
|
||||
ref={sectionRef}
|
||||
id="model-provider-marketplace"
|
||||
className="flex scroll-mt-4 flex-col gap-2"
|
||||
>
|
||||
<Divider className="my-2! h-px" />
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<div
|
||||
@@ -54,7 +91,7 @@ const InstallFromMarketplace = ({
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
|
||||
onClick={() => setCollapse((prev) => !prev)}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={!collapse}
|
||||
>
|
||||
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
|
||||
@@ -86,8 +123,11 @@ const InstallFromMarketplace = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type="area" />}
|
||||
{!isAllPluginsLoading && !collapse && (
|
||||
{!collapse && shouldLoadMarketplace && !hasLoadedInstalledPluginIds && (
|
||||
<Loading type="area" />
|
||||
)}
|
||||
{!collapse && hasLoadedInstalledPluginIds && isAllPluginsLoading && <Loading type="area" />}
|
||||
{!isAllPluginsLoading && !collapse && hasLoadedInstalledPluginIds && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { render } from '@/test/console/render'
|
||||
import ConfigModel from '../config-model'
|
||||
|
||||
describe('ConfigModel', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'common.operation.config',
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
name: 'common.modelProvider.auth.authorizationError',
|
||||
props: { loadBalancingInvalid: true },
|
||||
},
|
||||
])('announces loading for the $name action', ({ name, props }) => {
|
||||
render(<ConfigModel {...props} loading />)
|
||||
|
||||
const action = screen.getByRole('button', { name })
|
||||
expect(action).toHaveAttribute('aria-busy', 'true')
|
||||
expect(action).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
+23
@@ -1,5 +1,6 @@
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ManageCustomModelCredentials from '../manage-custom-model-credentials'
|
||||
|
||||
// Mock hooks
|
||||
@@ -17,6 +18,8 @@ vi.mock('../authorized', () => ({
|
||||
renderTrigger,
|
||||
items,
|
||||
popupTitle,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
renderTrigger: (o?: boolean) => React.ReactNode
|
||||
items: Array<{
|
||||
@@ -24,12 +27,17 @@ vi.mock('../authorized', () => ({
|
||||
selectedCredential?: { credential_id?: string }
|
||||
}>
|
||||
popupTitle: string
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<div data-testid="authorized-mock">
|
||||
<div data-testid="trigger-closed">{renderTrigger()}</div>
|
||||
<div data-testid="trigger-open">{renderTrigger(true)}</div>
|
||||
<div data-testid="popup-title">{popupTitle}</div>
|
||||
<div data-testid="items-count">{items.length}</div>
|
||||
<button type="button" data-open={isOpen} onClick={() => onOpenChange?.(false)}>
|
||||
close
|
||||
</button>
|
||||
<div data-testid="items-selected">
|
||||
{items.map((item, index) => (
|
||||
<span
|
||||
@@ -103,6 +111,21 @@ describe('ManageCustomModelCredentials', () => {
|
||||
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward controlled open state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
mockUseCustomModels.mockReturnValue([{ model: 'gpt-4' }])
|
||||
|
||||
render(
|
||||
<ManageCustomModelCredentials provider={mockProvider} isOpen onOpenChange={onOpenChange} />,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'close' })
|
||||
expect(closeButton).toHaveAttribute('data-open', 'true')
|
||||
await user.click(closeButton)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
|
||||
const mockModels = [
|
||||
{
|
||||
|
||||
+8
-1
@@ -26,9 +26,16 @@ const AddCustomModel = ({
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: AddCustomModelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const open = controlledOpen ?? localOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
setLocalOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
const canAddedModels = useCanAddedModels(provider)
|
||||
const noModels = !canAddedModels.length
|
||||
const { canUseCredential, canCreateCredential } = useCredentialPermissions()
|
||||
|
||||
+15
-3
@@ -7,12 +7,16 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ConfigModelProps = {
|
||||
onClick?: () => void
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
loadBalancingEnabled?: boolean
|
||||
loadBalancingInvalid?: boolean
|
||||
credentialRemoved?: boolean
|
||||
}
|
||||
const ConfigModel = ({
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
loadBalancingEnabled,
|
||||
loadBalancingInvalid,
|
||||
credentialRemoved,
|
||||
@@ -21,14 +25,19 @@ const ConfigModel = ({
|
||||
|
||||
if (loadBalancingInvalid) {
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-4.5 cursor-pointer items-center rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 system-2xs-medium-uppercase text-text-warning"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
aria-busy={loading || undefined}
|
||||
className="relative h-4.5 rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 system-2xs-medium-uppercase text-text-warning shadow-none hover:bg-components-badge-bg-dimm"
|
||||
onClick={onClick}
|
||||
>
|
||||
<RiScales3Line className="mr-0.5 size-3" />
|
||||
{t(($) => $['modelProvider.auth.authorizationError'], { ns: 'common' })}
|
||||
<StatusDot status="warning" className="absolute -top-px -right-px size-1.5" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +45,9 @@ const ConfigModel = ({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn('hidden shrink-0 group-hover:flex', credentialRemoved && 'flex')}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
+23
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider } from '../../../declarations'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCredentialStatus } from '../use-credential-status'
|
||||
@@ -48,6 +49,28 @@ describe('useCredentialStatus', () => {
|
||||
expect(restrictedRes.current.notAllowedToUse).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the summary credential usability flag', () => {
|
||||
const provider = {
|
||||
custom_configuration: {
|
||||
current_credential_id: '123',
|
||||
current_credential_name: 'Key',
|
||||
current_credential_usable: false,
|
||||
available_credentials: [
|
||||
{
|
||||
credential_id: '123',
|
||||
credential_name: 'Key',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as ModelProviderSummaryResponse
|
||||
|
||||
const { result } = renderHook(() => useCredentialStatus(provider))
|
||||
|
||||
expect(result.current.hasCredential).toBe(true)
|
||||
expect(result.current.authorized).toBe(false)
|
||||
expect(result.current.notAllowedToUse).toBe(true)
|
||||
})
|
||||
|
||||
it('handles undefined custom configuration gracefully', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCredentialStatus({ custom_configuration: {} } as ModelProvider),
|
||||
|
||||
+19
-4
@@ -1,7 +1,10 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useCredentialStatus = (provider: ModelProvider | undefined) => {
|
||||
export const useCredentialStatus = (
|
||||
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
|
||||
) => {
|
||||
const { current_credential_id, current_credential_name, available_credentials } =
|
||||
provider?.custom_configuration ?? {}
|
||||
const hasCredential = !!available_credentials?.length
|
||||
@@ -9,8 +12,20 @@ export const useCredentialStatus = (provider: ModelProvider | undefined) => {
|
||||
const currentCredential = available_credentials?.find(
|
||||
(credential) => credential.credential_id === current_credential_id,
|
||||
)
|
||||
const notAllowedToUse = currentCredential?.not_allowed_to_use
|
||||
const authorized = !!(current_credential_id && current_credential_name && !notAllowedToUse)
|
||||
const summaryCredentialUsable =
|
||||
provider && 'current_credential_usable' in provider.custom_configuration
|
||||
? provider.custom_configuration.current_credential_usable
|
||||
: undefined
|
||||
const notAllowedToUse =
|
||||
summaryCredentialUsable === false
|
||||
? true
|
||||
: currentCredential && 'not_allowed_to_use' in currentCredential
|
||||
? currentCredential.not_allowed_to_use
|
||||
: undefined
|
||||
const authorized =
|
||||
summaryCredentialUsable !== undefined
|
||||
? summaryCredentialUsable
|
||||
: !!(current_credential_id && current_credential_name && !notAllowedToUse)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
@@ -18,7 +33,7 @@ export const useCredentialStatus = (provider: ModelProvider | undefined) => {
|
||||
authorized,
|
||||
authRemoved,
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
current_credential_name: current_credential_name ?? undefined,
|
||||
available_credentials,
|
||||
notAllowedToUse,
|
||||
}),
|
||||
|
||||
+6
@@ -16,10 +16,14 @@ import { useCustomModels } from './hooks'
|
||||
type ManageCustomModelCredentialsProps = {
|
||||
provider: ModelProvider
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
const ManageCustomModelCredentials = ({
|
||||
provider,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: ManageCustomModelCredentialsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const customModels = useCustomModels(provider)
|
||||
@@ -59,6 +63,8 @@ const ManageCustomModelCredentials = ({
|
||||
: undefined,
|
||||
}))}
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
authParams={{
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { Model, ModelProvider } from '../declarations'
|
||||
import type { ModelSelectorProvider } from '../model-selector/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { OpenaiYellow } from '@/app/components/base/icons/src/public/llm'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
@@ -8,7 +10,7 @@ import { Theme } from '@/types/app'
|
||||
import { useLanguage } from '../hooks'
|
||||
|
||||
type ModelIconProps = {
|
||||
provider?: Model | ModelProvider
|
||||
provider?: Model | ModelProvider | ModelProviderSummaryResponse | ModelSelectorProvider
|
||||
modelName?: string
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
@@ -49,7 +51,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>
|
||||
)
|
||||
}
|
||||
|
||||
+29
-1
@@ -1,6 +1,6 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import {
|
||||
@@ -19,6 +19,8 @@ const invalidateInstalledPluginList = vi.fn()
|
||||
const handleOpenModal = vi.fn()
|
||||
const updateModelProviders = vi.fn()
|
||||
const updateModelList = vi.fn()
|
||||
const loadProviderDetail = vi.fn()
|
||||
let isLoadingProviderDetail = false
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
@@ -33,6 +35,10 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLazyModelProviderDetail: () => ({
|
||||
loadProviderDetail,
|
||||
isLoadingProviderDetail,
|
||||
}),
|
||||
useModelModalHandler: () => handleOpenModal,
|
||||
useUpdateModelList: () => updateModelList,
|
||||
useUpdateModelProviders: () => updateModelProviders,
|
||||
@@ -76,6 +82,7 @@ describe('AgentModelTrigger', () => {
|
||||
pluginInfo = null
|
||||
pluginLoading = false
|
||||
inModelList = true
|
||||
isLoadingProviderDetail = false
|
||||
})
|
||||
|
||||
it('should render loading state when plugin info is still fetching', () => {
|
||||
@@ -127,6 +134,14 @@ describe('AgentModelTrigger', () => {
|
||||
expect(invalidateInstalledPluginList).toHaveBeenCalledWith(PluginCategoryEnum.model)
|
||||
})
|
||||
|
||||
it('should not render the install action when the plugin has no package identifier', () => {
|
||||
pluginInfo = { latest_package_identifier: '' }
|
||||
|
||||
render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
|
||||
|
||||
expect(screen.queryByText('Install Plugin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show configuration action when provider requires setup', () => {
|
||||
modelProviders = [
|
||||
{
|
||||
@@ -145,6 +160,19 @@ describe('AgentModelTrigger', () => {
|
||||
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should load complete provider detail before opening configuration', async () => {
|
||||
const providerDetail = { provider: 'openai' } as ModelProvider
|
||||
modelProviders = [{ provider: 'openai', is_configured: false }] as unknown as ModelProvider[]
|
||||
loadProviderDetail.mockResolvedValue(providerDetail)
|
||||
|
||||
render(<AgentModelTrigger modelId="gpt-4" providerName="openai" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /notAuthorized/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleOpenModal).toHaveBeenCalledWith(providerDetail, 'predefined-model', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render unconfigured state when model is not selected', () => {
|
||||
render(<AgentModelTrigger />)
|
||||
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
|
||||
|
||||
+24
-23
@@ -6,15 +6,12 @@ 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.hoisted(() => ({
|
||||
mockUseInstalledPluginList: vi.fn(),
|
||||
const mockUseInstalledPluginList = vi.fn((_options: unknown) => ({
|
||||
data: { plugins: installedPlugins },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: (...args: unknown[]) => {
|
||||
mockUseInstalledPluginList(...args)
|
||||
return { data: { plugins: installedPlugins } }
|
||||
},
|
||||
useInstalledPluginList: (options: unknown) => mockUseInstalledPluginList(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
||||
@@ -29,23 +26,7 @@ describe('StatusIndicators', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
|
||||
})
|
||||
|
||||
it('reads the model-specific installed plugin list', () => {
|
||||
render(
|
||||
<StatusIndicators
|
||||
needsConfiguration={false}
|
||||
modelProvider={true}
|
||||
inModelList={false}
|
||||
disabled={true}
|
||||
pluginInfo={{ name: 'demo-plugin' }}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, {
|
||||
category: PluginCategoryEnum.model,
|
||||
})
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: { plugins: installedPlugins } })
|
||||
})
|
||||
|
||||
const getPopoverTrigger = (name: string) => {
|
||||
@@ -66,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 () => {
|
||||
@@ -80,6 +65,10 @@ describe('StatusIndicators', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await user.hover(getPopoverTrigger('nodes.agent.modelSelectorTooltips.deprecated'))
|
||||
|
||||
@@ -100,6 +89,10 @@ describe('StatusIndicators', () => {
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(mockUseInstalledPluginList).toHaveBeenLastCalledWith({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await user.hover(getPopoverTrigger('nodes.agent.modelNotSupport.title'))
|
||||
|
||||
@@ -119,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', () => {
|
||||
@@ -133,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', () => {
|
||||
|
||||
+22
-12
@@ -13,8 +13,13 @@ import {
|
||||
useModelInList,
|
||||
usePluginInfo,
|
||||
} from '@/service/use-plugins'
|
||||
import { CustomConfigurationStatusEnum, ModelTypeEnum } from '../declarations'
|
||||
import { useModelModalHandler, useUpdateModelList, useUpdateModelProviders } from '../hooks'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum } from '../declarations'
|
||||
import {
|
||||
useLazyModelProviderDetail,
|
||||
useModelModalHandler,
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ConfigurationButton from './configuration-button'
|
||||
import ModelDisplay from './model-display'
|
||||
@@ -47,14 +52,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
|
||||
const updateModelList = useUpdateModelList()
|
||||
const { modelProvider, needsConfiguration } = useMemo(() => {
|
||||
const modelProvider = modelProviders.find((item) => item.provider === providerName)
|
||||
const needsConfiguration =
|
||||
modelProvider?.custom_configuration.status === CustomConfigurationStatusEnum.noConfigure &&
|
||||
!(
|
||||
modelProvider.system_configuration.enabled === true &&
|
||||
modelProvider.system_configuration.quota_configurations.find(
|
||||
(item) => item.quota_type === modelProvider.system_configuration.current_quota_type,
|
||||
)
|
||||
)
|
||||
const needsConfiguration = modelProvider ? !modelProvider.is_configured : false
|
||||
return {
|
||||
modelProvider,
|
||||
needsConfiguration,
|
||||
@@ -63,10 +61,22 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
|
||||
const [installed, setInstalled] = useState(false)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const handleOpenModal = useModelModalHandler()
|
||||
const { loadProviderDetail, isLoadingProviderDetail } = useLazyModelProviderDetail(
|
||||
providerName ?? '',
|
||||
)
|
||||
|
||||
const { data: inModelList = false } = useModelInList(currentProvider, modelId)
|
||||
const { data: pluginInfo, isLoading: isPluginLoading } = usePluginInfo(providerName)
|
||||
|
||||
const handleConfigure = async () => {
|
||||
if (!providerName) return
|
||||
|
||||
const providerDetail = await loadProviderDetail()
|
||||
if (!providerDetail) return
|
||||
|
||||
handleOpenModal(providerDetail, ConfigurationMethodEnum.predefinedModel, undefined)
|
||||
}
|
||||
|
||||
if (modelId && isPluginLoading) return <Loading />
|
||||
|
||||
return (
|
||||
@@ -85,7 +95,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
|
||||
/>
|
||||
<ModelDisplay currentModel={currentModel} modelId={modelId} />
|
||||
{needsConfiguration && (
|
||||
<ConfigurationButton modelProvider={modelProvider} handleOpenModal={handleOpenModal} />
|
||||
<ConfigurationButton loading={isLoadingProviderDetail} onConfigure={handleConfigure} />
|
||||
)}
|
||||
<StatusIndicators
|
||||
needsConfiguration={needsConfiguration}
|
||||
@@ -95,7 +105,7 @@ const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
|
||||
pluginInfo={pluginInfo}
|
||||
t={translateWorkflow}
|
||||
/>
|
||||
{!installed && !modelProvider && pluginInfo && (
|
||||
{!installed && !modelProvider && pluginInfo?.latest_package_identifier && (
|
||||
<InstallPluginButton
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
|
||||
+5
-6
@@ -1,21 +1,20 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
|
||||
type ConfigurationButtonProps = {
|
||||
modelProvider: any
|
||||
handleOpenModal: any
|
||||
loading: boolean
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
const ConfigurationButton = ({ modelProvider, handleOpenModal }: ConfigurationButtonProps) => {
|
||||
const ConfigurationButton = ({ loading, onConfigure }: ConfigurationButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenModal(modelProvider, ConfigurationMethodEnum.predefinedModel, undefined)
|
||||
onConfigure()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
|
||||
+7
-5
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -54,8 +53,11 @@ const StatusIndicators = ({
|
||||
pluginInfo,
|
||||
t,
|
||||
}: StatusIndicatorsProps) => {
|
||||
const { data: pluginList } = useInstalledPluginList(false, 100, {
|
||||
const shouldLoadInstalledModelPlugins =
|
||||
!needsConfiguration && modelProvider && disabled && !inModelList && !!pluginInfo
|
||||
const { data: pluginList } = useInstalledPluginList({
|
||||
category: PluginCategoryEnum.model,
|
||||
enabled: shouldLoadInstalledModelPlugins,
|
||||
})
|
||||
const renderTooltipContent = (
|
||||
title: string,
|
||||
@@ -103,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
|
||||
@@ -115,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
|
||||
@@ -141,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>
|
||||
)}
|
||||
</>
|
||||
|
||||
+21
-23
@@ -1,21 +1,19 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProvider } from './declarations'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ModelProviderPluginSummary } from './index'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import ProviderAddedCard from './provider-added-card'
|
||||
import QuotaPanel from './provider-added-card/quota-panel'
|
||||
import { providerToPluginId } from './utils'
|
||||
|
||||
type ModelProviderPageBodyProps = {
|
||||
providers: ModelProvider[]
|
||||
filteredConfiguredProviders: ModelProvider[]
|
||||
filteredNotConfiguredProviders: ModelProvider[]
|
||||
providers: ModelProviderSummaryResponse[]
|
||||
filteredConfiguredProviders: ModelProviderSummaryResponse[]
|
||||
filteredNotConfiguredProviders: ModelProviderSummaryResponse[]
|
||||
isLoadingModelProviders: boolean
|
||||
showEmptyProvider: boolean
|
||||
showConfiguredProviders: boolean
|
||||
@@ -23,7 +21,7 @@ type ModelProviderPageBodyProps = {
|
||||
showMarketplace: boolean
|
||||
enableMarketplace: boolean
|
||||
searchText: string
|
||||
pluginDetailMap: Map<string, PluginDetail>
|
||||
pluginSummaryMap: Map<string, ModelProviderPluginSummary>
|
||||
onOpenMarketplace?: () => void
|
||||
}
|
||||
|
||||
@@ -107,26 +105,27 @@ function EmptyProviderState({
|
||||
|
||||
type ProviderCardListProps = {
|
||||
firstCardTarget?: string
|
||||
providers: ModelProvider[]
|
||||
pluginDetailMap: Map<string, PluginDetail>
|
||||
providers: ModelProviderSummaryResponse[]
|
||||
pluginSummaryMap: Map<string, ModelProviderPluginSummary>
|
||||
notConfigured?: boolean
|
||||
}
|
||||
|
||||
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
|
||||
return (
|
||||
pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
|
||||
)
|
||||
function isDebuggingProvider(
|
||||
provider: ModelProviderSummaryResponse,
|
||||
pluginSummaryMap: Map<string, ModelProviderPluginSummary>,
|
||||
) {
|
||||
return pluginSummaryMap.get(provider.plugin_id)?.source === 'remote'
|
||||
}
|
||||
|
||||
function ProviderCardList({
|
||||
firstCardTarget,
|
||||
providers,
|
||||
pluginDetailMap,
|
||||
pluginSummaryMap,
|
||||
notConfigured,
|
||||
}: ProviderCardListProps) {
|
||||
const sortedProviders = [...providers].sort((a, b) => {
|
||||
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
|
||||
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
|
||||
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginSummaryMap)
|
||||
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginSummaryMap)
|
||||
|
||||
if (aIsDebuggingPlugin === bIsDebuggingPlugin) return 0
|
||||
|
||||
@@ -136,7 +135,7 @@ function ProviderCardList({
|
||||
return (
|
||||
<div className="relative flex flex-col gap-2">
|
||||
{sortedProviders.map((provider, index) => {
|
||||
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
|
||||
const pluginSummary = pluginSummaryMap.get(provider.plugin_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -146,7 +145,7 @@ function ProviderCardList({
|
||||
<ProviderAddedCard
|
||||
notConfigured={notConfigured}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetail}
|
||||
pluginSummary={pluginSummary}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -166,7 +165,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
showMarketplace,
|
||||
enableMarketplace,
|
||||
searchText,
|
||||
pluginDetailMap,
|
||||
pluginSummaryMap,
|
||||
onOpenMarketplace,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -203,7 +202,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
<ProviderCardList
|
||||
firstCardTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction}
|
||||
providers={filteredConfiguredProviders}
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
pluginSummaryMap={pluginSummaryMap}
|
||||
/>
|
||||
)}
|
||||
{showNotConfiguredProviders && (
|
||||
@@ -219,14 +218,13 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
}
|
||||
providers={filteredNotConfiguredProviders}
|
||||
notConfigured
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
pluginSummaryMap={pluginSummaryMap}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMarketplace && (
|
||||
<div>
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModelSelector from '../index'
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useCurrentProviderAndModel: () => ({
|
||||
getCurrentProviderAndModel: () => ({
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
}),
|
||||
|
||||
+19
-9
@@ -3,9 +3,10 @@ import type { DefaultModel, Model, ModelItem } from '../../declarations'
|
||||
import type { ModelSelectorPreviewPayload } from '../popup-item'
|
||||
import { Combobox } from '@langgenius/dify-ui/combobox'
|
||||
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
@@ -150,7 +151,11 @@ const createComboboxNode = (node: ReactElement, onValueChange = vi.fn()) => (
|
||||
)
|
||||
|
||||
const renderWithCombobox = (node: ReactElement, onValueChange = vi.fn()) => {
|
||||
return render(createComboboxNode(node, onValueChange))
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
|
||||
data: [makeProvider()],
|
||||
})
|
||||
return renderWithConsoleQuery(createComboboxNode(node, onValueChange), { queryClient })
|
||||
}
|
||||
|
||||
describe('PopupItem', () => {
|
||||
@@ -295,7 +300,7 @@ describe('PopupItem', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open model modal when clicking add on unconfigured model', () => {
|
||||
it('should open model modal when clicking add on unconfigured model', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const { rerender } = renderWithCombobox(
|
||||
<PopupItem
|
||||
@@ -312,7 +317,9 @@ describe('PopupItem', () => {
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
expect(mockSetShowModelModal).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const call = mockSetShowModelModal.mock.calls[0]![0] as { onSaveCallback?: () => void }
|
||||
call.onSaveCallback?.()
|
||||
@@ -338,6 +345,9 @@ describe('PopupItem', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('COMMON.OPERATION.ADD'))
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as
|
||||
| { onSaveCallback?: () => void }
|
||||
| undefined
|
||||
@@ -495,18 +505,18 @@ describe('PopupItem', () => {
|
||||
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the dropdown through dropdown content callbacks', () => {
|
||||
it('should close the dropdown through dropdown content callbacks', async () => {
|
||||
const onHide = vi.fn()
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'close dropdown' }))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the credential dropdown enabled for manage-only users', () => {
|
||||
it('should keep the credential dropdown enabled for manage-only users', async () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
@@ -517,6 +527,6 @@ describe('PopupItem', () => {
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'close dropdown' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'close dropdown' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+100
-34
@@ -1,5 +1,6 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Model, ModelItem, ModelProvider } from '../../declarations'
|
||||
import type { Model, ModelItem } from '../../declarations'
|
||||
import type { PopupProps } from '../popup'
|
||||
import { Combobox } from '@langgenius/dify-ui/combobox'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
@@ -43,13 +44,8 @@ vi.mock('@/utils/tool-call', () => ({
|
||||
supportFunctionCall: mockSupportFunctionCall,
|
||||
}))
|
||||
|
||||
type MockMarketplacePlugin = {
|
||||
plugin_id: string
|
||||
latest_package_identifier: string
|
||||
}
|
||||
|
||||
type MockContextProvider = Pick<
|
||||
ModelProvider,
|
||||
ModelProviderSummaryResponse,
|
||||
| 'provider'
|
||||
| 'label'
|
||||
| 'icon_small'
|
||||
@@ -58,13 +54,12 @@ type MockContextProvider = Pick<
|
||||
| 'system_configuration'
|
||||
>
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({
|
||||
current: [] as MockMarketplacePlugin[],
|
||||
isLoading: false,
|
||||
}))
|
||||
const mockContextModelProviders = vi.hoisted(() => ({
|
||||
current: [] as MockContextProvider[],
|
||||
}))
|
||||
const mockContextModelProviderPlugins = vi.hoisted(() => ({
|
||||
current: {} as Record<string, { plugin_id: string }>,
|
||||
}))
|
||||
const mockTrialModels = vi.hoisted(() => ({
|
||||
current: ['test-openai', 'test-anthropic'] as string[],
|
||||
}))
|
||||
@@ -73,10 +68,6 @@ vi.mock('../../hooks', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => mockLanguage,
|
||||
useMarketplaceAllPlugins: () => ({
|
||||
plugins: mockMarketplacePlugins.current,
|
||||
isLoading: mockMarketplacePlugins.isLoading,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -92,7 +83,10 @@ vi.mock('../popup-item', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
|
||||
useProviderContext: () => ({
|
||||
modelProviders: mockContextModelProviders.current,
|
||||
modelProviderPlugins: mockContextModelProviderPlugins.current,
|
||||
}),
|
||||
}))
|
||||
|
||||
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange'>
|
||||
@@ -160,6 +154,12 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
|
||||
}))
|
||||
|
||||
const mockFetchPluginInfoFromMarketPlace = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchPluginInfoFromMarketPlace: (params: Record<string, string>) =>
|
||||
mockFetchPluginInfoFromMarketPlace(params),
|
||||
}))
|
||||
|
||||
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
@@ -240,9 +240,15 @@ describe('Popup', () => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
mockMarketplacePlugins.current = []
|
||||
mockMarketplacePlugins.isLoading = false
|
||||
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
|
||||
data: {
|
||||
plugin: {
|
||||
latest_package_identifier: 'langgenius/openai:1.0.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
mockContextModelProviders.current = []
|
||||
mockContextModelProviderPlugins.current = {}
|
||||
mockTrialModels.current = ['test-openai', 'test-anthropic']
|
||||
mockSearchParams.current = new URLSearchParams()
|
||||
Object.assign(mockTrialCredits, {
|
||||
@@ -1001,6 +1007,52 @@ describe('Popup', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should only mark API key fallback when the current credential is usable', () => {
|
||||
Object.assign(mockTrialCredits, {
|
||||
credits: 0,
|
||||
totalCredits: 200,
|
||||
isExhausted: true,
|
||||
})
|
||||
mockContextModelProviders.current = [
|
||||
makeContextProvider({
|
||||
provider: 'test-openai',
|
||||
custom_configuration: {
|
||||
status: 'active',
|
||||
current_credential_usable: false,
|
||||
} as MockContextProvider['custom_configuration'],
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
}),
|
||||
]
|
||||
|
||||
const { rerender } = renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
|
||||
'data-has-api-key-fallback',
|
||||
'false',
|
||||
)
|
||||
|
||||
mockContextModelProviders.current = [
|
||||
makeContextProvider({
|
||||
provider: 'test-openai',
|
||||
custom_configuration: {
|
||||
status: 'active',
|
||||
current_credential_usable: true,
|
||||
} as MockContextProvider['custom_configuration'],
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
}),
|
||||
]
|
||||
rerender(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute(
|
||||
'data-has-api-key-fallback',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show credits exhausted alert when only non-trial system providers are exhausted', () => {
|
||||
Object.assign(mockTrialCredits, {
|
||||
credits: 0,
|
||||
@@ -1105,6 +1157,9 @@ describe('Popup', () => {
|
||||
|
||||
it('should render marketplace providers that are not installed', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
|
||||
mockContextModelProviderPlugins.current = {
|
||||
'langgenius/openai': { plugin_id: 'langgenius/openai' },
|
||||
}
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
@@ -1148,6 +1203,9 @@ describe('Popup', () => {
|
||||
} as MockContextProvider['system_configuration'],
|
||||
}),
|
||||
]
|
||||
mockContextModelProviderPlugins.current = {
|
||||
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
|
||||
}
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
@@ -1169,6 +1227,9 @@ describe('Popup', () => {
|
||||
} as MockContextProvider['system_configuration'],
|
||||
}),
|
||||
]
|
||||
mockContextModelProviderPlugins.current = {
|
||||
'langgenius/anthropic': { plugin_id: 'langgenius/anthropic' },
|
||||
}
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
@@ -1191,10 +1252,18 @@ describe('Popup', () => {
|
||||
expect(screen.getByText('TestOpenAI'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide a marketplace provider when its plugin is already installed', () => {
|
||||
mockContextModelProviderPlugins.current = {
|
||||
'langgenius/openai': { plugin_id: 'langgenius/openai' },
|
||||
}
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should install plugin when clicking install button', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
@@ -1205,13 +1274,14 @@ describe('Popup', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
|
||||
})
|
||||
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({
|
||||
org: 'langgenius',
|
||||
name: 'openai',
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle install failure gracefully', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
@@ -1229,9 +1299,6 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should run checkTaskStatus when not all_installed', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue(undefined)
|
||||
|
||||
@@ -1249,11 +1316,8 @@ describe('Popup', () => {
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip install requests when marketplace plugins are still loading', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockMarketplacePlugins.isLoading = true
|
||||
it('should skip install requests when the marketplace plugin lookup fails', async () => {
|
||||
mockFetchPluginInfoFromMarketPlace.mockRejectedValue(new Error('Not found'))
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
@@ -1264,8 +1328,10 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip install requests when the marketplace plugin cannot be found', async () => {
|
||||
mockMarketplacePlugins.current = []
|
||||
it('should skip install requests when the marketplace plugin has no package identifier', async () => {
|
||||
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
|
||||
data: { plugin: { latest_package_identifier: '' } },
|
||||
})
|
||||
|
||||
renderPopup(<PopupHarness modelList={[]} onHide={vi.fn()} />)
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import { useCurrentProviderAndModel } from '../hooks'
|
||||
import { getCurrentProviderAndModel } from '../hooks'
|
||||
import ModelSelectorTrigger from './model-selector-trigger'
|
||||
import Popup from './popup'
|
||||
import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types'
|
||||
@@ -58,7 +58,7 @@ function ModelSelector({
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const { currentProvider, currentModel } = useCurrentProviderAndModel(modelList, defaultModel)
|
||||
const { currentProvider, currentModel } = getCurrentProviderAndModel(modelList, defaultModel)
|
||||
const currentValue = useMemo<ModelSelectorValue | null>(() => {
|
||||
if (!currentProvider || !currentModel) return null
|
||||
|
||||
|
||||
+3
-5
@@ -10,7 +10,6 @@ type MarketplaceSectionProps = {
|
||||
marketplaceProviders: ModelProviderQuotaGetPaid[]
|
||||
marketplaceCollapsed: boolean
|
||||
installingProvider: ModelProviderQuotaGetPaid | null
|
||||
isMarketplacePluginsLoading: boolean
|
||||
canInstallPlugin: boolean
|
||||
theme?: string
|
||||
onMarketplaceCollapsedChange: (collapsed: boolean) => void
|
||||
@@ -22,7 +21,6 @@ function MarketplaceSection({
|
||||
marketplaceProviders,
|
||||
marketplaceCollapsed,
|
||||
installingProvider,
|
||||
isMarketplacePluginsLoading,
|
||||
canInstallPlugin,
|
||||
theme,
|
||||
onMarketplaceCollapsedChange,
|
||||
@@ -42,7 +40,7 @@ function MarketplaceSection({
|
||||
<div className="flex h-5.5 items-center pr-2 pl-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary"
|
||||
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
|
||||
>
|
||||
{t(($) => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })}
|
||||
@@ -78,7 +76,7 @@ function MarketplaceSection({
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
disabled={isInstalling}
|
||||
onClick={() => onInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && (
|
||||
@@ -95,7 +93,7 @@ function MarketplaceSection({
|
||||
{onOpenMarketplace ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left"
|
||||
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
|
||||
+11
-8
@@ -1,5 +1,6 @@
|
||||
import type { DefaultModel, Model, ModelItem, TypeWithI18N } from '../declarations'
|
||||
import type { ModelSelectorModelPredicate } from './types'
|
||||
import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { DefaultModel, ModelItem } from '../declarations'
|
||||
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
|
||||
import Fuse from 'fuse.js'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import { ModelFeatureEnum } from '../declarations'
|
||||
@@ -29,7 +30,7 @@ type FilterModelSelectorModelsParams = {
|
||||
aiCreditVisibleProviders: Set<string>
|
||||
defaultModel?: DefaultModel
|
||||
inputValue: string
|
||||
installedModelList: Model[]
|
||||
installedModelList: ModelSelectorProvider[]
|
||||
modelPredicate?: ModelSelectorModelPredicate
|
||||
scopeFeatures: ModelFeatureEnum[]
|
||||
searchIndex: ModelSelectorSearchIndex
|
||||
@@ -62,10 +63,12 @@ const normalizeModelSearchValue = (value: string) =>
|
||||
|
||||
const looksLikeModelQuery = (value: string) => /\d/.test(value)
|
||||
|
||||
const getLabelSearchValues = (label: TypeWithI18N, language: string) => {
|
||||
if (label[language] !== undefined) return [label[language]]
|
||||
const getLabelSearchValues = (label: I18nObject | null | undefined, language: string) => {
|
||||
if (!label) return []
|
||||
const localizedValue = label[language as keyof I18nObject]
|
||||
if (localizedValue) return [localizedValue]
|
||||
|
||||
return Array.from(new Set(Object.values(label)))
|
||||
return Array.from(new Set(Object.values(label).filter((value): value is string => !!value)))
|
||||
}
|
||||
|
||||
const getProviderKeySearchValues = (provider: string) => {
|
||||
@@ -87,7 +90,7 @@ const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFe
|
||||
}
|
||||
|
||||
export const createModelSelectorSearchIndex = (
|
||||
installedModelList: Model[],
|
||||
installedModelList: ModelSelectorProvider[],
|
||||
language: string,
|
||||
): ModelSelectorSearchIndex => {
|
||||
const providerEntries = installedModelList.map<ProviderSearchEntry>((model) => {
|
||||
@@ -176,7 +179,7 @@ export const filterModelSelectorModels = ({
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
})
|
||||
.filter((model): model is Model => model !== null)
|
||||
.filter((model): model is ModelSelectorProvider => model !== null)
|
||||
|
||||
if (defaultModel?.provider) {
|
||||
filtered.sort((a, b) => {
|
||||
|
||||
+44
-24
@@ -1,6 +1,6 @@
|
||||
import type { PreviewCardHandle } from '@langgenius/dify-ui/preview-card'
|
||||
import type { DefaultModel, Model, ModelItem } from '../declarations'
|
||||
import type { ModelSelectorModelPredicate } from './types'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { DefaultModel, ModelItem } from '../declarations'
|
||||
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
@@ -9,29 +9,36 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
|
||||
import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks'
|
||||
import {
|
||||
useLanguage,
|
||||
useLazyModelProviderDetail,
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
|
||||
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import { useCredentialPanelState as useCredentialPanelInfo } from '../provider-added-card/use-credential-panel-state'
|
||||
|
||||
export type ModelSelectorPreviewPayload = {
|
||||
provider: Model
|
||||
provider: ModelSelectorProvider
|
||||
modelItem: ModelItem
|
||||
}
|
||||
|
||||
type PreviewCardHandle = NonNullable<ComponentProps<typeof PreviewCardTrigger>['handle']>
|
||||
|
||||
type PopupItemProps = {
|
||||
defaultModel?: DefaultModel
|
||||
model: Model
|
||||
model: ModelSelectorProvider
|
||||
modelPredicate?: ModelSelectorModelPredicate
|
||||
modelSuggestionPredicate?: ModelSelectorModelPredicate
|
||||
previewCardHandle: PreviewCardHandle<ModelSelectorPreviewPayload>
|
||||
previewCardHandle: PreviewCardHandle
|
||||
onPreviewCardClose: () => void
|
||||
onHide: () => void
|
||||
}
|
||||
@@ -54,15 +61,18 @@ function PopupItem({
|
||||
const updateModelList = useUpdateModelList()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const currentProvider = modelProviders.find((provider) => provider.provider === model.provider)
|
||||
const { providerDetail, loadProviderDetail } = useLazyModelProviderDetail(model.provider)
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
|
||||
const handleOpenModelModal = () => {
|
||||
const handleOpenModelModal = async () => {
|
||||
if (!canCreateCredential) return
|
||||
|
||||
if (!currentProvider) return
|
||||
const detail = await loadProviderDetail()
|
||||
if (!detail) return
|
||||
setShowModelModal({
|
||||
payload: {
|
||||
currentProvider,
|
||||
currentProvider: detail,
|
||||
currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
|
||||
},
|
||||
onSaveCallback: () => {
|
||||
@@ -75,7 +85,7 @@ function PopupItem({
|
||||
})
|
||||
}
|
||||
|
||||
const state = useCredentialPanelState(currentProvider)
|
||||
const state = useCredentialPanelInfo(currentProvider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
|
||||
const groupItems = useMemo(
|
||||
() =>
|
||||
@@ -97,6 +107,14 @@ function PopupItem({
|
||||
setDropdownOpen(false)
|
||||
onHide()
|
||||
}, [onHide])
|
||||
const handleDropdownOpenChange = async (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setDropdownOpen(false)
|
||||
return
|
||||
}
|
||||
const detail = await loadProviderDetail()
|
||||
if (detail) setDropdownOpen(true)
|
||||
}
|
||||
|
||||
if (!currentProvider) return null
|
||||
|
||||
@@ -105,10 +123,10 @@ function PopupItem({
|
||||
<div className="sticky top-0 z-1 flex h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left"
|
||||
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
<span className="truncate">{model.label[language] || model.label.en_US}</span>
|
||||
<span className="truncate">{renderI18nObject(model.label, language)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary',
|
||||
@@ -116,18 +134,18 @@ function PopupItem({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
|
||||
<PopoverTrigger
|
||||
disabled={!canOpenCredentialDropdown}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover"
|
||||
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{isUsingCredits ? (
|
||||
hasCredits ? (
|
||||
<>
|
||||
<CreditsCoin className="size-3" />
|
||||
<span className="i-custom-vender-line-financeandecommerce-credits-coin size-3" />
|
||||
<span className="ml-1 truncate">
|
||||
{t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })}
|
||||
</span>
|
||||
@@ -160,13 +178,15 @@ function PopupItem({
|
||||
}
|
||||
/>
|
||||
<PopoverContent placement="bottom-end">
|
||||
<DropdownContent
|
||||
provider={currentProvider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
onClose={handleCloseDropdown}
|
||||
/>
|
||||
{providerDetail && (
|
||||
<DropdownContent
|
||||
provider={providerDetail}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
onClose={handleCloseDropdown}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
+48
-53
@@ -1,6 +1,6 @@
|
||||
import type { DefaultModel, Model } from '../declarations'
|
||||
import type { ModelSelectorPreviewPayload } from './popup-item'
|
||||
import type { ModelSelectorModelPredicate } from './types'
|
||||
import type { ModelSelectorModelPredicate, ModelSelectorProvider } from './types'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { ComboboxList } from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
@@ -20,17 +20,14 @@ import {
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
|
||||
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelFeatureEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
||||
import { CustomConfigurationStatusEnum, ModelFeatureEnum, ModelTypeEnum } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import ModelBadge from '../model-badge'
|
||||
import ModelIcon from '../model-icon'
|
||||
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
||||
@@ -89,10 +86,13 @@ function Popup({
|
||||
)
|
||||
const { theme } = useTheme()
|
||||
const language = useLanguage()
|
||||
const [previewCardHandle] = useState(() => createPreviewCardHandle<ModelSelectorPreviewPayload>())
|
||||
const previewCardHandle = useMemo(
|
||||
() => createPreviewCardHandle<ModelSelectorPreviewPayload>(),
|
||||
[],
|
||||
)
|
||||
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
|
||||
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
|
||||
const { modelProviders } = useProviderContext()
|
||||
const { modelProviders, modelProviderPlugins = {} } = useProviderContext()
|
||||
const { data: enableMarketplace } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: (systemFeatures) => systemFeatures.enable_marketplace,
|
||||
@@ -101,11 +101,6 @@ function Popup({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { plugins: allPlugins, isLoading: isMarketplacePluginsLoading } = useMarketplaceAllPlugins(
|
||||
modelProviders,
|
||||
'',
|
||||
enableMarketplace,
|
||||
)
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
const { canInstallPlugin } = useWorkspacePluginInstallPermission()
|
||||
@@ -141,71 +136,68 @@ function Popup({
|
||||
const hasApiKeyFallback = modelProviders.some((provider) => {
|
||||
const isApiKeyActive =
|
||||
provider.custom_configuration?.status === CustomConfigurationStatusEnum.active
|
||||
return isApiKeyActive && providerSupportsCredits(provider, trialModels, deploymentEdition)
|
||||
return (
|
||||
isApiKeyActive &&
|
||||
provider.custom_configuration.current_credential_usable &&
|
||||
providerSupportsCredits(provider, trialModels, deploymentEdition)
|
||||
)
|
||||
})
|
||||
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (key: ModelProviderQuotaGetPaid) => {
|
||||
if (
|
||||
!enableMarketplace ||
|
||||
!canInstallPlugin ||
|
||||
!allPlugins ||
|
||||
isMarketplacePluginsLoading ||
|
||||
installingProvider
|
||||
)
|
||||
return
|
||||
if (!enableMarketplace || !canInstallPlugin || installingProvider) return
|
||||
const pluginId = providerKeyToPluginId[key]
|
||||
const plugin = allPlugins.find((p) => p.plugin_id === pluginId)
|
||||
if (!plugin) return
|
||||
|
||||
const uniqueIdentifier = plugin.latest_package_identifier
|
||||
const [org, name] = pluginId.split('/')
|
||||
if (!org || !name) return
|
||||
setInstallingProvider(key)
|
||||
try {
|
||||
const pluginInfo = await fetchPluginInfoFromMarketPlace({ org, name })
|
||||
const uniqueIdentifier = pluginInfo.data.plugin.latest_package_identifier
|
||||
if (!uniqueIdentifier) return
|
||||
const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier)
|
||||
if (!all_installed) {
|
||||
const { check } = checkTaskStatus()
|
||||
await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier })
|
||||
}
|
||||
refreshPluginList(plugin)
|
||||
refreshPluginList({ category: PluginCategoryEnum.model })
|
||||
} catch {
|
||||
} finally {
|
||||
setInstallingProvider(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
allPlugins,
|
||||
enableMarketplace,
|
||||
canInstallPlugin,
|
||||
installPackageFromMarketPlace,
|
||||
installingProvider,
|
||||
isMarketplacePluginsLoading,
|
||||
refreshPluginList,
|
||||
],
|
||||
)
|
||||
|
||||
const installedModelList = useMemo(() => {
|
||||
const modelMap = new Map(modelList.map((model) => [model.provider, model]))
|
||||
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
|
||||
const installedProvider = installedProviderMap.get(providerKey)
|
||||
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap<ModelSelectorProvider>(
|
||||
(providerKey) => {
|
||||
const installedProvider = installedProviderMap.get(providerKey)
|
||||
|
||||
if (!installedProvider) return []
|
||||
if (!installedProvider) return []
|
||||
|
||||
const matchedModel = modelMap.get(providerKey)
|
||||
if (matchedModel) return [matchedModel]
|
||||
const matchedModel = modelMap.get(providerKey)
|
||||
if (matchedModel) return [matchedModel]
|
||||
|
||||
if (!aiCreditVisibleProviders.has(providerKey)) return []
|
||||
if (!aiCreditVisibleProviders.has(providerKey)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
provider: installedProvider.provider,
|
||||
icon_small: installedProvider.icon_small,
|
||||
icon_small_dark: installedProvider.icon_small_dark,
|
||||
label: installedProvider.label,
|
||||
models: [],
|
||||
status: ModelStatusEnum.active,
|
||||
},
|
||||
]
|
||||
})
|
||||
return [
|
||||
{
|
||||
provider: installedProvider.provider,
|
||||
icon_small: installedProvider.icon_small,
|
||||
icon_small_dark: installedProvider.icon_small_dark,
|
||||
label: installedProvider.label,
|
||||
models: [],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
const otherModels = modelList.filter(
|
||||
(model) =>
|
||||
!MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid),
|
||||
@@ -245,9 +237,13 @@ function Popup({
|
||||
const marketplaceProviders = useMemo(() => {
|
||||
if (!enableMarketplace) return []
|
||||
|
||||
const installedProviders = new Set(modelProviders.map((provider) => provider.provider))
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter((key) => !installedProviders.has(key))
|
||||
}, [enableMarketplace, modelProviders])
|
||||
const installedPluginIds = new Set(
|
||||
Object.values(modelProviderPlugins).map((plugin) => plugin.plugin_id),
|
||||
)
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(
|
||||
(key) => !installedPluginIds.has(providerKeyToPluginId[key]),
|
||||
)
|
||||
}, [enableMarketplace, modelProviderPlugins])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
onHide()
|
||||
@@ -307,7 +303,6 @@ function Popup({
|
||||
marketplaceProviders={marketplaceProviders}
|
||||
marketplaceCollapsed={marketplaceCollapsed}
|
||||
installingProvider={installingProvider}
|
||||
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
theme={theme}
|
||||
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
|
||||
@@ -322,7 +317,7 @@ function Popup({
|
||||
<ModelSelectorPreviewCard
|
||||
capabilitiesLabel={t(($) => $['model.capabilities'], { ns: 'common' })}
|
||||
language={language}
|
||||
payload={payload}
|
||||
payload={payload as ModelSelectorPreviewPayload | undefined}
|
||||
/>
|
||||
)}
|
||||
</PreviewCard>
|
||||
|
||||
+14
-2
@@ -1,11 +1,23 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import type { I18nObject } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelItem } from '../declarations'
|
||||
|
||||
export type ModelSelectorValue = {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type ModelSelectorModelPredicate = (provider: Model, modelItem: ModelItem) => boolean
|
||||
export type ModelSelectorProvider = {
|
||||
provider: string
|
||||
icon_small?: I18nObject | null
|
||||
icon_small_dark?: I18nObject | null
|
||||
label: I18nObject
|
||||
models: ModelItem[]
|
||||
}
|
||||
|
||||
export type ModelSelectorModelPredicate = (
|
||||
provider: ModelSelectorProvider,
|
||||
modelItem: ModelItem,
|
||||
) => boolean
|
||||
|
||||
export const isSameModelSelectorValue = (
|
||||
itemValue: ModelSelectorValue,
|
||||
|
||||
+24
-13
@@ -1,6 +1,6 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ModelProviderPluginSummary } from '../../index'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@@ -229,15 +229,11 @@ describe('ProviderAddedCard', () => {
|
||||
renderWithQueryClient(
|
||||
<ProviderAddedCard
|
||||
provider={mockProvider}
|
||||
pluginDetail={
|
||||
pluginSummary={
|
||||
{
|
||||
plugin_id: 'provider-plugin',
|
||||
plugin_unique_identifier: 'provider-plugin@1.0.0',
|
||||
declaration: {
|
||||
author: 'langgenius',
|
||||
description: { en_US: 'Provider plugin' },
|
||||
},
|
||||
} as PluginDetail
|
||||
installation_id: 'provider-plugin@1.0.0',
|
||||
} as ModelProviderPluginSummary
|
||||
}
|
||||
/>,
|
||||
)
|
||||
@@ -352,29 +348,44 @@ describe('ProviderAddedCard', () => {
|
||||
const customConfigProvider = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
custom_configuration: { has_custom_models: true },
|
||||
} as unknown as ModelProvider
|
||||
const { unmount } = renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
|
||||
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.modelProvider.addModel' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom model actions when user can configure models without credential permissions', () => {
|
||||
const customConfigProvider = {
|
||||
...mockProvider,
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
custom_configuration: { has_custom_models: false },
|
||||
} as unknown as ModelProvider
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config']
|
||||
|
||||
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
|
||||
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import LazyCustomModelActions from '../lazy-custom-model-actions'
|
||||
|
||||
const { mockLoadProviderDetail, mockOpenModelModal, providerDetail } = vi.hoisted(() => {
|
||||
const providerDetail = {
|
||||
provider: 'langgenius/openai/openai',
|
||||
custom_configuration: {
|
||||
custom_models: [{ model: 'custom-gpt' }],
|
||||
can_added_models: [{ model: 'custom-gpt-2', model_type: 'llm' }],
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
mockLoadProviderDetail: vi.fn().mockResolvedValue(providerDetail),
|
||||
mockOpenModelModal: vi.fn(),
|
||||
providerDetail,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../hooks', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
useLazyModelProviderDetail: () => {
|
||||
const [detail, setDetail] = useState<typeof providerDetail>()
|
||||
|
||||
return {
|
||||
providerDetail: detail,
|
||||
loadProviderDetail: async () => {
|
||||
const loadedDetail = await mockLoadProviderDetail()
|
||||
setDetail(loadedDetail)
|
||||
return loadedDetail
|
||||
},
|
||||
isLoadingProviderDetail: false,
|
||||
}
|
||||
},
|
||||
useModelModalHandler: () => mockOpenModelModal,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
AddCustomModel: ({ open }: { open?: boolean }) => (
|
||||
<div data-open={open} data-testid="add-custom-model" />
|
||||
),
|
||||
ManageCustomModelCredentials: ({ isOpen }: { isOpen?: boolean }) => (
|
||||
<div data-open={isOpen} data-testid="manage-custom-model" />
|
||||
),
|
||||
}))
|
||||
|
||||
const createProvider = (hasCustomModels: boolean) =>
|
||||
({
|
||||
provider: 'langgenius/openai/openai',
|
||||
custom_configuration: {
|
||||
has_custom_models: hasCustomModels,
|
||||
},
|
||||
}) as ModelProviderSummaryResponse
|
||||
|
||||
describe('LazyCustomModelActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads provider detail and opens credential management on demand', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<LazyCustomModelActions provider={createProvider(true)} />)
|
||||
|
||||
expect(mockLoadProviderDetail).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('manage-custom-model')).toHaveAttribute('data-open', 'true')
|
||||
expect(mockLoadProviderDetail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render credential management without custom models', () => {
|
||||
render(<LazyCustomModelActions provider={createProvider(false)} />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.modelProvider.auth.manageCredentials' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import ModelList from '../model-list'
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => ({
|
||||
workspacePermissionKeys: ['plugin.model_config'],
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLazyModelProviderDetail: () => ({
|
||||
loadProviderDetail: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../model-load-balancing-modal', () => {
|
||||
throw new Error('Failed to load model load balancing modal')
|
||||
})
|
||||
|
||||
vi.mock('../model-list-item', () => ({
|
||||
default: ({
|
||||
model,
|
||||
onModifyLoadBalancing,
|
||||
}: {
|
||||
model: ModelItem
|
||||
onModifyLoadBalancing: (model: ModelItem) => void
|
||||
}) => (
|
||||
<button type="button" onClick={() => onModifyLoadBalancing(model)}>
|
||||
{model.model}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ModelList dynamic import failure', () => {
|
||||
const provider = {
|
||||
provider: 'test-provider',
|
||||
configurate_methods: [],
|
||||
} as unknown as ModelProvider
|
||||
const model = {
|
||||
model: 'gpt-4',
|
||||
model_type: 'llm',
|
||||
fetch_from: 'system',
|
||||
} as unknown as ModelItem
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('clears the loading dialog and reports an error when the modal module cannot load', async () => {
|
||||
render(<ModelList provider={provider} models={[model]} onCollapse={vi.fn()} />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+197
-82
@@ -1,12 +1,18 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import type { ModelLoadBalancingModalProps } from '../model-load-balancing-modal'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { render } from '@/test/console/render'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import ModelList from '../model-list'
|
||||
|
||||
const mockLoadProviderDetail = vi.fn()
|
||||
const { mockLoadModelLoadBalancingModal } = vi.hoisted(() => ({
|
||||
mockLoadModelLoadBalancingModal: vi.fn(),
|
||||
}))
|
||||
const { mockToastError } = vi.hoisted(() => ({
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
let mockWorkspacePermissionKeys: string[] = [
|
||||
'plugin.model_config',
|
||||
'credential.manage',
|
||||
@@ -20,51 +26,65 @@ vi.mock('@/context/permission-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
const LazyComponent = React.lazy(loader)
|
||||
return function DynamicComponent(props: Record<string, unknown>) {
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(LazyComponent, props),
|
||||
)
|
||||
}
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLazyModelProviderDetail: () => ({
|
||||
loadProviderDetail: mockLoadProviderDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../model-load-balancing-modal', () => ({
|
||||
default: ({ model, onClose, onSave, open, provider }: ModelLoadBalancingModalProps) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={`Load balancing for ${model.model}`}>
|
||||
<span>{provider.provider}</span>
|
||||
<button type="button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave?.(provider.provider)
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
const MockModelLoadBalancingModal = ({
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
onClose?: () => void
|
||||
onSave?: (provider: string) => void
|
||||
}) => (
|
||||
<div data-testid="model-load-balancing-modal">
|
||||
<button type="button" onClick={() => onSave?.('test-provider')}>
|
||||
Save load balancing
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
Close load balancing
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
vi.mock('../model-load-balancing-modal', () => mockLoadModelLoadBalancingModal())
|
||||
|
||||
vi.mock('../lazy-custom-model-actions', () => ({
|
||||
default: ({ provider }: { provider: ModelProvider }) => (
|
||||
<>
|
||||
{(provider.custom_configuration.custom_models?.length ?? 0) > 0 && (
|
||||
<div data-testid="manage-credentials" />
|
||||
)}
|
||||
<button type="button" data-testid="add-custom-model">
|
||||
common.modelProvider.addModel
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../model-list-item', () => ({
|
||||
default: ({
|
||||
model,
|
||||
isLoadingLoadBalancing,
|
||||
isLoadBalancingDisabled,
|
||||
onModifyLoadBalancing,
|
||||
}: {
|
||||
model: ModelItem
|
||||
isLoadingLoadBalancing?: boolean
|
||||
isLoadBalancingDisabled?: boolean
|
||||
onModifyLoadBalancing: (model: ModelItem) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Modify load balancing for ${model.model}`}
|
||||
disabled={isLoadingLoadBalancing || isLoadBalancingDisabled}
|
||||
onClick={() => onModifyLoadBalancing(model)}
|
||||
>
|
||||
{model.model}
|
||||
@@ -72,15 +92,11 @@ vi.mock('../model-list-item', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
ManageCustomModelCredentials: () => <div data-testid="manage-credentials" />,
|
||||
AddCustomModel: () => <div data-testid="add-custom-model" />,
|
||||
}))
|
||||
|
||||
describe('ModelList', () => {
|
||||
const mockProvider = {
|
||||
provider: 'test-provider',
|
||||
configurate_methods: ['customizableModel'],
|
||||
custom_configuration: { custom_models: [] },
|
||||
} as unknown as ModelProvider
|
||||
|
||||
const mockModels = [
|
||||
@@ -90,12 +106,69 @@ describe('ModelList', () => {
|
||||
|
||||
const mockOnCollapse = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
const createSummaryProvider = (): ModelProviderSummaryResponse => ({
|
||||
provider: 'test-provider',
|
||||
plugin_id: 'test-plugin',
|
||||
label: { en_US: 'Test provider', zh_Hans: 'Test provider' },
|
||||
supported_model_types: ['llm'],
|
||||
configurate_methods: ['customizable-model'],
|
||||
preferred_provider_type: 'system',
|
||||
is_configured: true,
|
||||
custom_configuration: {
|
||||
status: 'active',
|
||||
has_custom_models: false,
|
||||
available_credentials: [],
|
||||
current_credential_usable: false,
|
||||
},
|
||||
system_configuration: { enabled: false },
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLoadProviderDetail.mockResolvedValue(mockProvider)
|
||||
mockLoadModelLoadBalancingModal.mockResolvedValue({
|
||||
default: MockModelLoadBalancingModal,
|
||||
})
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config', 'credential.manage', 'credential.use']
|
||||
})
|
||||
|
||||
it('should allow reopening the loading dialog after it is dismissed before the module is available', async () => {
|
||||
let resolveModule:
|
||||
| ((module: { default: typeof MockModelLoadBalancingModal }) => void)
|
||||
| undefined
|
||||
mockLoadModelLoadBalancingModal.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ default: typeof MockModelLoadBalancingModal }>((resolve) => {
|
||||
resolveModule = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
render(
|
||||
<ModelList
|
||||
provider={mockProvider}
|
||||
models={mockModels}
|
||||
onCollapse={mockOnCollapse}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
expect(await screen.findByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
resolveModule?.({ default: MockModelLoadBalancingModal })
|
||||
|
||||
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model count and model items', () => {
|
||||
render(
|
||||
<ModelList
|
||||
@@ -106,12 +179,8 @@ describe('ModelList', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(/modelProvider\.modelsNum/).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Modify load balancing for gpt-3.5' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'gpt-4' }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'gpt-3.5' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger collapse when collapsed label is clicked', () => {
|
||||
@@ -129,8 +198,7 @@ describe('ModelList', () => {
|
||||
expect(mockOnCollapse).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open the selected model and reset the payload after close', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should open load balancing modal for selected model', async () => {
|
||||
render(
|
||||
<ModelList
|
||||
provider={mockProvider}
|
||||
@@ -140,28 +208,78 @@ describe('ModelList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }))
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
expect(mockLoadProviderDetail).not.toHaveBeenCalled()
|
||||
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const firstDialog = await screen.findByRole('dialog', {
|
||||
name: 'Load balancing for gpt-4',
|
||||
})
|
||||
expect(within(firstDialog).getByText('test-provider')).toBeInTheDocument()
|
||||
it('should load provider detail before opening load balancing from a summary card', async () => {
|
||||
const summaryProvider = createSummaryProvider()
|
||||
|
||||
await user.click(within(firstDialog).getByRole('button', { name: 'Close' }))
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
render(
|
||||
<ModelList
|
||||
provider={summaryProvider}
|
||||
models={mockModels}
|
||||
onCollapse={mockOnCollapse}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-3.5' }))
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Load balancing for gpt-3.5' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(mockLoadProviderDetail).toHaveBeenCalledOnce()
|
||||
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable load balancing while provider detail is loading', async () => {
|
||||
let resolveProviderDetail: (provider: ModelProvider) => void = () => {}
|
||||
mockLoadProviderDetail.mockReturnValue(
|
||||
new Promise<ModelProvider>((resolve) => {
|
||||
resolveProviderDetail = resolve
|
||||
}),
|
||||
)
|
||||
const summaryProvider = createSummaryProvider()
|
||||
|
||||
render(
|
||||
<ModelList
|
||||
provider={summaryProvider}
|
||||
models={mockModels}
|
||||
onCollapse={mockOnCollapse}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
const modelButton = screen.getByRole('button', { name: 'gpt-4' })
|
||||
await user.click(modelButton)
|
||||
|
||||
expect(modelButton).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'gpt-3.5' })).toBeDisabled()
|
||||
|
||||
resolveProviderDetail(mockProvider)
|
||||
expect(await screen.findByTestId('model-load-balancing-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show an error when provider detail cannot be loaded', async () => {
|
||||
mockLoadProviderDetail.mockResolvedValue(undefined)
|
||||
const summaryProvider = createSummaryProvider()
|
||||
|
||||
render(
|
||||
<ModelList
|
||||
provider={summaryProvider}
|
||||
models={mockModels}
|
||||
onCollapse={mockOnCollapse}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
expect(screen.queryByTestId('model-load-balancing-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide custom model actions without plugin.model_config', () => {
|
||||
@@ -230,8 +348,7 @@ describe('ModelList', () => {
|
||||
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should refresh the provider and close after saving load balancing changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should call onSave (onChange) and close the load balancing modal', async () => {
|
||||
render(
|
||||
<ModelList
|
||||
provider={mockProvider}
|
||||
@@ -241,19 +358,13 @@ describe('ModelList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modify load balancing for gpt-4' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Load balancing for gpt-4',
|
||||
})
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Save' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||
await screen.findByTestId('model-load-balancing-modal')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save load balancing' }))
|
||||
expect(mockOnChange).toHaveBeenCalledWith('test-provider')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Load balancing for gpt-4' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close load balancing' }))
|
||||
expect(screen.queryByTestId('model-load-balancing-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide custom model actions when provider uses fetchFromRemote only', () => {
|
||||
@@ -307,10 +418,11 @@ describe('ModelList', () => {
|
||||
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show custom model actions when provider is configurable and user can configure models', () => {
|
||||
it('should show Add Model but hide Manage Credentials for configurable providers', () => {
|
||||
const configurableProvider = {
|
||||
provider: 'test-provider',
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
custom_configuration: { custom_models: [] },
|
||||
} as unknown as ModelProvider
|
||||
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config']
|
||||
@@ -324,14 +436,17 @@ describe('ModelList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('manage-credentials'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-custom-model'))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.modelProvider.addModel' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide custom model actions when provider is configurable but user cannot configure models', () => {
|
||||
const configurableProvider = {
|
||||
provider: 'test-provider',
|
||||
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||
custom_configuration: { custom_models: [] },
|
||||
} as unknown as ModelProvider
|
||||
|
||||
mockWorkspacePermissionKeys = []
|
||||
|
||||
+206
-11
@@ -1,7 +1,11 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ModelProviderPluginSummary } from '../../index'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import ProviderCardActions from '../provider-card-actions'
|
||||
|
||||
@@ -13,6 +17,16 @@ const mockShowPluginInfo = vi.fn()
|
||||
const mockShowDeleteConfirm = vi.fn()
|
||||
const mockSetTargetVersion = vi.fn()
|
||||
const mockSetVersionPickerOpen = vi.fn()
|
||||
const { mockNormalizeInstalledPluginDetail, mockUninstallPlugin, mockUseVersionListOfPlugin } =
|
||||
vi.hoisted(() => ({
|
||||
mockNormalizeInstalledPluginDetail: vi.fn(),
|
||||
mockUninstallPlugin: vi.fn(),
|
||||
mockUseVersionListOfPlugin: vi.fn(),
|
||||
}))
|
||||
const mockPluginSettingsAccess = vi.hoisted(() => ({
|
||||
canDeletePlugin: true,
|
||||
canUpdatePlugin: true,
|
||||
}))
|
||||
|
||||
let mockHeaderState = {
|
||||
modalStates: {
|
||||
@@ -48,10 +62,6 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () =
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({
|
||||
HeaderModals: ({
|
||||
targetVersion,
|
||||
@@ -72,10 +82,7 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
usePluginSettingsAccess: () => ({
|
||||
canDeletePlugin: true,
|
||||
canUpdatePlugin: true,
|
||||
}),
|
||||
usePluginSettingsAccess: () => mockPluginSettingsAccess,
|
||||
default: () => ({
|
||||
canUpdate: true,
|
||||
}),
|
||||
@@ -89,6 +96,15 @@ vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (...args: unknown[]) => mockGetMarketplaceUrl(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
normalizeInstalledPluginDetail: mockNormalizeInstalledPluginDetail,
|
||||
useVersionListOfPlugin: mockUseVersionListOfPlugin,
|
||||
}))
|
||||
|
||||
const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail =>
|
||||
({
|
||||
plugin_id: 'plugin-id',
|
||||
@@ -106,6 +122,18 @@ const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail =>
|
||||
...overrides,
|
||||
}) as PluginDetail
|
||||
|
||||
const createSummary = (
|
||||
overrides: Partial<ModelProviderPluginSummary> = {},
|
||||
): ModelProviderPluginSummary => ({
|
||||
installation_id: 'installation-id',
|
||||
plugin_id: 'langgenius/provider-plugin',
|
||||
plugin_unique_identifier: 'langgenius/provider-plugin@1.0.0',
|
||||
runtime_type: 'local',
|
||||
source: 'github',
|
||||
version: '1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProviderCardActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -129,12 +157,170 @@ describe('ProviderCardActions', () => {
|
||||
mockGetMarketplaceUrl.mockReturnValue(
|
||||
'https://marketplace.example.com/plugins/langgenius/provider-plugin',
|
||||
)
|
||||
mockNormalizeInstalledPluginDetail.mockReturnValue(
|
||||
createDetail({
|
||||
source: PluginSource.github,
|
||||
meta: {
|
||||
repo: 'langgenius/provider-plugin',
|
||||
version: '1.0.0',
|
||||
package: 'provider-plugin.difypkg',
|
||||
},
|
||||
}),
|
||||
)
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
mockUseVersionListOfPlugin.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
versions: [
|
||||
{
|
||||
version: '0.9.0',
|
||||
unique_identifier: 'plugin@0.9.0',
|
||||
created_at: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mockPluginSettingsAccess.canDeletePlugin = true
|
||||
mockPluginSettingsAccess.canUpdatePlugin = true
|
||||
})
|
||||
|
||||
it('should render version controls for marketplace plugins', () => {
|
||||
it('should load plugin detail and continue the requested info action', async () => {
|
||||
mockHeaderState = {
|
||||
...mockHeaderState,
|
||||
hasNewVersion: false,
|
||||
isFromMarketplace: false,
|
||||
isFromGitHub: true,
|
||||
}
|
||||
const rendered = render(
|
||||
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
|
||||
)
|
||||
const fetchQuery = vi
|
||||
.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
.mockResolvedValue({ plugins: [{}] })
|
||||
|
||||
openActionsMenu()
|
||||
fireEvent.click(screen.getByText('plugin.detailPanel.operation.info'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowPluginInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(mockNormalizeInstalledPluginDetail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove from summary without loading plugin detail', async () => {
|
||||
const rendered = render(
|
||||
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
|
||||
)
|
||||
const fetchQuery = vi.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
const invalidateQueries = vi.spyOn(rendered.queryClient, 'invalidateQueries')
|
||||
|
||||
openActionsMenu()
|
||||
fireEvent.click(screen.getByText('plugin.detailPanel.operation.remove'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('installation-id')
|
||||
})
|
||||
expect(fetchQuery).not.toHaveBeenCalled()
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.installedIds.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: commonQueryKeys.modelProviderDetails,
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplacePlugins'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['marketplaceCollectionPlugins'] })
|
||||
})
|
||||
|
||||
it('should only render an interactive summary version when marketplace updates are allowed', () => {
|
||||
const { rerender } = render(
|
||||
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '1.0.0' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<ProviderCardActions
|
||||
summary={createSummary({ source: 'marketplace' })}
|
||||
providerLabel="Provider Plugin"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '1.0.0' })).toBeInTheDocument()
|
||||
|
||||
mockPluginSettingsAccess.canUpdatePlugin = false
|
||||
rerender(
|
||||
<ProviderCardActions
|
||||
summary={createSummary({ source: 'marketplace' })}
|
||||
providerLabel="Provider Plugin"
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: '1.0.0' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use the summary latest version when updating a marketplace plugin', async () => {
|
||||
mockNormalizeInstalledPluginDetail.mockReturnValue(
|
||||
createDetail({
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'plugin-id@1.0.0',
|
||||
}),
|
||||
)
|
||||
const rendered = render(
|
||||
<ProviderCardActions
|
||||
summary={createSummary({
|
||||
source: 'marketplace',
|
||||
latestVersion: '2.0.0',
|
||||
latestUniqueIdentifier: 'plugin-id@2.0.0',
|
||||
})}
|
||||
providerLabel="Provider Plugin"
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetTargetVersion).toHaveBeenCalledWith({
|
||||
version: '2.0.0',
|
||||
unique_identifier: 'plugin-id@2.0.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should render version controls for marketplace plugins and handle manual version selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockHeaderState = {
|
||||
...mockHeaderState,
|
||||
versionPicker: {
|
||||
...mockHeaderState.versionPicker,
|
||||
isShow: true,
|
||||
},
|
||||
}
|
||||
render(<ProviderCardActions detail={createDetail()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: '1.0.0' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '1.0.0' })).not.toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^0\.9\.0/ }))
|
||||
|
||||
expect(mockSetTargetVersion).toHaveBeenCalledWith({
|
||||
version: '0.9.0',
|
||||
unique_identifier: 'plugin@0.9.0',
|
||||
isDowngrade: true,
|
||||
})
|
||||
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show a compact debug badge after the version for debugging plugins', () => {
|
||||
@@ -160,6 +346,15 @@ describe('ProviderCardActions', () => {
|
||||
expect(mockHandleUpdate).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should not update a marketplace plugin without a latest package identifier', () => {
|
||||
render(<ProviderCardActions detail={createDetail({ latest_unique_identifier: '' })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
|
||||
|
||||
expect(mockSetTargetVersion).not.toHaveBeenCalled()
|
||||
expect(mockHandleUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass the marketplace detail url to the operation dropdown', () => {
|
||||
render(<ProviderCardActions detail={createDetail()} />)
|
||||
|
||||
|
||||
+137
-10
@@ -8,6 +8,7 @@ let mockWorkspaceData:
|
||||
| {
|
||||
trial_credits: number
|
||||
trial_credits_used: number
|
||||
is_unlimited?: boolean
|
||||
trial_credits_exhausted_at?: number
|
||||
next_credit_reset_date: number
|
||||
}
|
||||
@@ -24,6 +25,42 @@ let mockPlugins = [
|
||||
latest_package_identifier: 'openai@1.0.0',
|
||||
},
|
||||
]
|
||||
const mockFetchManifestFromMarketPlace = vi.fn(async (_uniqueIdentifier: string) => ({
|
||||
data: {
|
||||
plugin: {
|
||||
name: 'registry-openai',
|
||||
org: 'marketplace-cache',
|
||||
icon: '',
|
||||
label: { en_US: 'OpenAI' },
|
||||
category: 'model' as const,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
brief: {},
|
||||
introduction: '',
|
||||
verified: true,
|
||||
install_count: 0,
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' as const },
|
||||
from: 'package' as const,
|
||||
},
|
||||
},
|
||||
}))
|
||||
const mockFetchPluginInfoFromMarketPlace = vi.fn(async (_params: Record<string, string>) => ({
|
||||
data: {
|
||||
plugin: {
|
||||
category: 'model' as const,
|
||||
latest_package_identifier: 'openai@1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchManifestFromMarketPlace: (uniqueIdentifier: string) =>
|
||||
mockFetchManifestFromMarketPlace(uniqueIdentifier),
|
||||
fetchPluginInfoFromMarketPlace: (params: Record<string, string>) =>
|
||||
mockFetchPluginInfoFromMarketPlace(params),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/llm', () => {
|
||||
const Icon = ({ label }: { label: string }) => <span>{label}</span>
|
||||
@@ -39,16 +76,20 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
|
||||
|
||||
vi.mock('../use-trial-credits', () => ({
|
||||
useTrialCredits: () => {
|
||||
const totalCredits = Math.max(mockWorkspaceData?.trial_credits ?? 0, 0)
|
||||
const isUnlimited = mockWorkspaceData?.is_unlimited ?? false
|
||||
const rawTotalCredits = mockWorkspaceData?.trial_credits ?? 0
|
||||
const rawUsedCredits = mockWorkspaceData?.trial_credits_used ?? 0
|
||||
const normalizedUsedCredits = Math.max(rawUsedCredits, 0)
|
||||
const usedCredits = Math.min(normalizedUsedCredits, totalCredits)
|
||||
const credits = Math.max(totalCredits - usedCredits, 0)
|
||||
const totalCredits = isUnlimited ? rawTotalCredits : Math.max(rawTotalCredits, 0)
|
||||
const usedCredits = isUnlimited
|
||||
? rawUsedCredits
|
||||
: Math.min(Math.max(rawUsedCredits, 0), totalCredits)
|
||||
const credits = isUnlimited ? -1 : Math.max(totalCredits - usedCredits, 0)
|
||||
return {
|
||||
credits,
|
||||
usedCredits,
|
||||
totalCredits,
|
||||
isExhausted: credits <= 0,
|
||||
isUnlimited,
|
||||
isExhausted: !isUnlimited && credits <= 0,
|
||||
isLoading: mockWorkspaceIsPending && !mockWorkspaceData,
|
||||
exhaustedAt: mockWorkspaceData?.trial_credits_exhausted_at,
|
||||
nextCreditResetDate: mockWorkspaceData?.next_credit_reset_date,
|
||||
@@ -86,8 +127,23 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div>
|
||||
default: ({
|
||||
manifest,
|
||||
uniqueIdentifier,
|
||||
onClose,
|
||||
}: {
|
||||
manifest: { from: string; icon: string; name: string; org: string }
|
||||
uniqueIdentifier: string
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div
|
||||
data-icon={manifest.icon}
|
||||
data-from={manifest.from}
|
||||
data-name={manifest.name}
|
||||
data-org={manifest.org}
|
||||
data-unique-identifier={uniqueIdentifier}
|
||||
data-testid="install-modal"
|
||||
>
|
||||
<span>install modal</span>
|
||||
<button type="button" onClick={onClose}>
|
||||
close install
|
||||
@@ -115,6 +171,17 @@ describe('QuotaPanel', () => {
|
||||
mockWorkspaceIsPending = false
|
||||
mockTrialModels = ['langgenius/openai/openai']
|
||||
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
|
||||
mockFetchManifestFromMarketPlace.mockClear()
|
||||
mockFetchPluginInfoFromMarketPlace.mockReset()
|
||||
mockFetchPluginInfoFromMarketPlace.mockResolvedValue({
|
||||
data: {
|
||||
plugin: {
|
||||
category: 'model',
|
||||
latest_package_identifier: 'openai@1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should render loading state', () => {
|
||||
@@ -167,19 +234,79 @@ describe('QuotaPanel', () => {
|
||||
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open install modal when clicking an unsupported trial provider', () => {
|
||||
it('should show unlimited credits instead of backend sentinel values', () => {
|
||||
mockWorkspaceData = {
|
||||
trial_credits: -1,
|
||||
trial_credits_used: 999,
|
||||
is_unlimited: true,
|
||||
next_credit_reset_date: 0,
|
||||
}
|
||||
|
||||
renderQuotaPanel(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.getByText('common.license.unlimited')).toBeInTheDocument()
|
||||
expect(screen.queryByText('999')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('-1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/modelProvider\.used/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open install modal when clicking an unsupported trial provider', async () => {
|
||||
renderQuotaPanel(<QuotaPanel providers={[]} />)
|
||||
|
||||
fireEvent.click(screen.getByText('openai'))
|
||||
|
||||
expect(screen.getByText('install modal')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
|
||||
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('openai@1.0.0')
|
||||
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({
|
||||
org: 'langgenius',
|
||||
name: 'openai',
|
||||
})
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute(
|
||||
'data-unique-identifier',
|
||||
'openai@1.0.0',
|
||||
)
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-icon', 'marketplace')
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-from', 'marketplace')
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-org', 'langgenius')
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-name', 'openai')
|
||||
})
|
||||
|
||||
it('should prevent duplicate marketplace requests while a provider is loading', async () => {
|
||||
mockFetchPluginInfoFromMarketPlace.mockImplementation(() => new Promise(() => {}))
|
||||
renderQuotaPanel(<QuotaPanel providers={[]} />)
|
||||
const providerButton = screen.getByLabelText(/modelNotSupported/)
|
||||
|
||||
fireEvent.click(providerButton)
|
||||
fireEvent.click(providerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(providerButton).toHaveAttribute('aria-busy', 'true')
|
||||
expect(providerButton).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should allow retrying marketplace installation after a request failure', async () => {
|
||||
mockFetchPluginInfoFromMarketPlace.mockRejectedValueOnce(new Error('Marketplace unavailable'))
|
||||
renderQuotaPanel(<QuotaPanel providers={[]} />)
|
||||
const providerButton = screen.getByLabelText(/modelNotSupported/)
|
||||
|
||||
fireEvent.click(providerButton)
|
||||
|
||||
await waitFor(() => expect(providerButton).toHaveAttribute('aria-busy', 'false'))
|
||||
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(providerButton)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
|
||||
expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should close install modal when provider becomes installed', async () => {
|
||||
const { rerender } = renderQuotaPanel(<QuotaPanel providers={[]} />)
|
||||
|
||||
fireEvent.click(screen.getByText('openai'))
|
||||
expect(screen.getByText('install modal')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('install modal')).toBeInTheDocument())
|
||||
|
||||
rerender(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
|
||||
+56
-18
@@ -13,11 +13,15 @@ vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
post: {
|
||||
queryOptions: (options?: object) => ({
|
||||
queryKey: ['console', 'workspaces', 'current', 'post'],
|
||||
...options,
|
||||
}),
|
||||
modelProviders: {
|
||||
credits: {
|
||||
get: {
|
||||
queryOptions: (options?: object) => ({
|
||||
queryKey: ['console', 'workspaces', 'current', 'model-providers', 'credits', 'get'],
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -28,10 +32,13 @@ describe('useTrialCredits', () => {
|
||||
const mockTrialCreditsQuery = (
|
||||
data:
|
||||
| {
|
||||
trial_credits?: number
|
||||
trial_credits_used?: number
|
||||
trial_credits_exhausted_at?: number
|
||||
next_credit_reset_date?: number
|
||||
quota_limit?: number | null
|
||||
quota_used?: number | null
|
||||
remaining_credits?: number | null
|
||||
is_unlimited?: boolean
|
||||
is_exhausted?: boolean
|
||||
exhausted_at?: number | null
|
||||
next_credit_reset_date?: number | null
|
||||
}
|
||||
| undefined,
|
||||
isPending = false,
|
||||
@@ -45,9 +52,11 @@ describe('useTrialCredits', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTrialCreditsQuery({
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 40,
|
||||
trial_credits_exhausted_at: undefined,
|
||||
quota_limit: 100,
|
||||
quota_used: 40,
|
||||
remaining_credits: 60,
|
||||
is_exhausted: false,
|
||||
exhausted_at: undefined,
|
||||
next_credit_reset_date: 1775001600,
|
||||
})
|
||||
})
|
||||
@@ -60,6 +69,7 @@ describe('useTrialCredits', () => {
|
||||
credits: 60,
|
||||
usedCredits: 40,
|
||||
totalCredits: 100,
|
||||
isUnlimited: false,
|
||||
isExhausted: false,
|
||||
isLoading: false,
|
||||
exhaustedAt: undefined,
|
||||
@@ -70,8 +80,10 @@ describe('useTrialCredits', () => {
|
||||
it('should keep the hook out of loading state during a background refetch', () => {
|
||||
mockTrialCreditsQuery(
|
||||
{
|
||||
trial_credits: 80,
|
||||
trial_credits_used: 20,
|
||||
quota_limit: 80,
|
||||
quota_used: 20,
|
||||
remaining_credits: 60,
|
||||
is_exhausted: false,
|
||||
next_credit_reset_date: 1777593600,
|
||||
},
|
||||
true,
|
||||
@@ -82,6 +94,7 @@ describe('useTrialCredits', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.credits).toBe(60)
|
||||
expect(result.current.usedCredits).toBe(20)
|
||||
expect(result.current.isUnlimited).toBe(false)
|
||||
expect(result.current.isExhausted).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -96,6 +109,7 @@ describe('useTrialCredits', () => {
|
||||
credits: 0,
|
||||
usedCredits: 0,
|
||||
totalCredits: 0,
|
||||
isUnlimited: false,
|
||||
isExhausted: true,
|
||||
isLoading: true,
|
||||
exhaustedAt: undefined,
|
||||
@@ -103,11 +117,13 @@ describe('useTrialCredits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should clamp negative remaining credits to zero', () => {
|
||||
it('should use the backend exhausted state', () => {
|
||||
mockTrialCreditsQuery({
|
||||
trial_credits: 10,
|
||||
trial_credits_used: 99,
|
||||
trial_credits_exhausted_at: 1772323200,
|
||||
quota_limit: 10,
|
||||
quota_used: 10,
|
||||
remaining_credits: 0,
|
||||
is_exhausted: true,
|
||||
exhausted_at: 1772323200,
|
||||
next_credit_reset_date: undefined,
|
||||
})
|
||||
|
||||
@@ -118,5 +134,27 @@ describe('useTrialCredits', () => {
|
||||
expect(result.current.isExhausted).toBe(true)
|
||||
expect(result.current.exhaustedAt).toBe(1772323200)
|
||||
})
|
||||
|
||||
it('should preserve the unlimited state without interpreting sentinel credits as usage', () => {
|
||||
mockTrialCreditsQuery({
|
||||
quota_limit: -1,
|
||||
quota_used: 999,
|
||||
remaining_credits: -1,
|
||||
is_unlimited: true,
|
||||
is_exhausted: false,
|
||||
exhausted_at: null,
|
||||
next_credit_reset_date: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTrialCredits())
|
||||
|
||||
expect(result.current).toMatchObject({
|
||||
credits: -1,
|
||||
usedCredits: 999,
|
||||
totalCredits: -1,
|
||||
isUnlimited: true,
|
||||
isExhausted: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
|
||||
import type { CardVariant } from './use-credential-panel-state'
|
||||
@@ -11,16 +12,16 @@ import { useChangeProviderPriority } from './use-change-provider-priority'
|
||||
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
|
||||
|
||||
type CredentialPanelProps = {
|
||||
provider: ModelProvider
|
||||
provider: ModelProviderSummaryResponse | ModelProvider
|
||||
}
|
||||
|
||||
type CredentialPanelContentProps = {
|
||||
provider: ModelProvider
|
||||
provider: ModelProviderSummaryResponse | ModelProvider
|
||||
state: ReturnType<typeof useCredentialPanelState>
|
||||
isChangingPriority: boolean
|
||||
onChangePriority: (key: PreferredProviderTypeEnum) => void
|
||||
renderActions?: (props: {
|
||||
provider: ModelProvider
|
||||
provider: ModelProviderSummaryResponse | ModelProvider
|
||||
state: ReturnType<typeof useCredentialPanelState>
|
||||
isChangingPriority: boolean
|
||||
onChangePriority: (key: PreferredProviderTypeEnum) => void
|
||||
|
||||
+29
-44
@@ -1,16 +1,13 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import type { ModelProviderPluginSummary } from '../index'
|
||||
import type { ModelProviderQuotaGetPaid } from '../utils'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AddCustomModel,
|
||||
ManageCustomModelCredentials,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
@@ -31,20 +28,22 @@ import {
|
||||
normalizeModelProviderModelsResponse,
|
||||
} from '../utils'
|
||||
import CredentialPanel from './credential-panel'
|
||||
import LazyCustomModelActions from './lazy-custom-model-actions'
|
||||
import ModelList from './model-list'
|
||||
import ProviderCardActions from './provider-card-actions'
|
||||
|
||||
type ProviderAddedCardProps = {
|
||||
layout?: 'list' | 'grid'
|
||||
notConfigured?: boolean
|
||||
provider: ModelProvider
|
||||
pluginDetail?: PluginDetail
|
||||
provider: ModelProviderSummaryResponse | ModelProvider
|
||||
pluginSummary?: ModelProviderPluginSummary
|
||||
}
|
||||
|
||||
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
layout = 'list',
|
||||
notConfigured,
|
||||
provider,
|
||||
pluginDetail,
|
||||
pluginSummary,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
@@ -57,11 +56,11 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
const currentProviderName = provider.provider
|
||||
const expanded = useModelProviderListExpanded(currentProviderName)
|
||||
const setExpanded = useSetModelProviderListExpanded(currentProviderName)
|
||||
const supportsPredefinedModel = provider.configurate_methods.includes(
|
||||
ConfigurationMethodEnum.predefinedModel,
|
||||
const supportsPredefinedModel = provider.configurate_methods.some(
|
||||
(method) => method === ConfigurationMethodEnum.predefinedModel,
|
||||
)
|
||||
const supportsCustomizableModel = provider.configurate_methods.includes(
|
||||
ConfigurationMethodEnum.customizableModel,
|
||||
const supportsCustomizableModel = provider.configurate_methods.some(
|
||||
(method) => method === ConfigurationMethodEnum.customizableModel,
|
||||
)
|
||||
const systemConfig = provider.system_configuration
|
||||
const {
|
||||
@@ -120,14 +119,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
}, [expanded, loading, refetchModelList, setExpanded])
|
||||
|
||||
const providerLabel = renderI18nObject(provider.label, language)
|
||||
const description = renderI18nObject(
|
||||
provider.description ||
|
||||
pluginDetail?.declaration.description ||
|
||||
provider.help?.title ||
|
||||
provider.label,
|
||||
language,
|
||||
)
|
||||
const organization = pluginDetail?.declaration.author || currentProviderName.split('/')[0]
|
||||
const description = renderI18nObject(provider.description || provider.label, language)
|
||||
const organization = currentProviderName.split('/')[0]
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
@@ -158,8 +151,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
>
|
||||
{providerLabel}
|
||||
</div>
|
||||
{pluginDetail && (
|
||||
<ProviderCardActions detail={pluginDetail} onUpdate={refreshPluginData} />
|
||||
{pluginSummary && (
|
||||
<ProviderCardActions
|
||||
summary={pluginSummary}
|
||||
providerLabel={providerLabel}
|
||||
onUpdate={refreshPluginData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex h-4 min-w-0 items-center gap-2 system-xs-regular text-text-tertiary">
|
||||
@@ -182,7 +179,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
{(showModelProvider || !notConfigured) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover"
|
||||
className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })}
|
||||
onClick={handleOpenModelList}
|
||||
>
|
||||
@@ -213,15 +210,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
{showCredential && <CredentialPanel provider={provider} />}
|
||||
{showCustomModelActions && (
|
||||
<div className="flex shrink-0">
|
||||
<ManageCustomModelCredentials
|
||||
provider={provider}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<AddCustomModel
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<LazyCustomModelActions provider={provider} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -253,8 +242,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
<div className="grow px-1 pt-1 pb-0.5">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<ProviderIcon provider={provider} />
|
||||
{pluginDetail && (
|
||||
<ProviderCardActions detail={pluginDetail} onUpdate={refreshPluginData} />
|
||||
{pluginSummary && (
|
||||
<ProviderCardActions
|
||||
summary={pluginSummary}
|
||||
providerLabel={providerLabel}
|
||||
onUpdate={refreshPluginData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
@@ -270,7 +263,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
{(showModelProvider || !notConfigured) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left hover:bg-components-button-ghost-bg-hover"
|
||||
className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['modelProvider.showModels'], { ns: 'common' })}
|
||||
onClick={handleOpenModelList}
|
||||
>
|
||||
@@ -291,15 +284,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
)}
|
||||
{showCustomModelActions && (
|
||||
<div className="flex grow justify-end">
|
||||
<ManageCustomModelCredentials
|
||||
provider={provider}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<AddCustomModel
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<LazyCustomModelActions provider={provider} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AddCustomModel,
|
||||
ManageCustomModelCredentials,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
|
||||
import { useLazyModelProviderDetail, useModelModalHandler } from '../hooks'
|
||||
|
||||
type ProviderSummary = ModelProviderSummaryResponse | ModelProvider
|
||||
|
||||
export default function LazyCustomModelActions({ provider }: { provider: ProviderSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const handleOpenModelModal = useModelModalHandler()
|
||||
const [isAddOpen, setIsAddOpen] = useState(false)
|
||||
const [isManageOpen, setIsManageOpen] = useState(false)
|
||||
const { providerDetail, loadProviderDetail, isLoadingProviderDetail } =
|
||||
useLazyModelProviderDetail(provider.provider)
|
||||
const hasCustomModels =
|
||||
'has_custom_models' in provider.custom_configuration
|
||||
? provider.custom_configuration.has_custom_models
|
||||
: !!provider.custom_configuration.custom_models?.length
|
||||
|
||||
const handleAddClick = async () => {
|
||||
const detail = await loadProviderDetail()
|
||||
if (!detail) return
|
||||
|
||||
if (detail.custom_configuration.can_added_models?.length) {
|
||||
setIsAddOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
handleOpenModelModal(detail, ConfigurationMethodEnum.customizableModel, undefined, {
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.configCustomModel,
|
||||
})
|
||||
}
|
||||
|
||||
const handleManageClick = async () => {
|
||||
const detail = await loadProviderDetail()
|
||||
if (!detail) return
|
||||
|
||||
setIsManageOpen(true)
|
||||
}
|
||||
|
||||
if (providerDetail) {
|
||||
return (
|
||||
<>
|
||||
{hasCustomModels && (
|
||||
<ManageCustomModelCredentials
|
||||
provider={providerDetail}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
isOpen={isManageOpen}
|
||||
onOpenChange={setIsManageOpen}
|
||||
/>
|
||||
)}
|
||||
<AddCustomModel
|
||||
provider={providerDetail}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
open={isAddOpen}
|
||||
onOpenChange={setIsAddOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasCustomModels && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
loading={isLoadingProviderDetail}
|
||||
onClick={handleManageClick}
|
||||
className="mr-0.5 text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['modelProvider.auth.manageCredentials'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
loading={isLoadingProviderDetail}
|
||||
onClick={handleAddClick}
|
||||
className="text-text-tertiary"
|
||||
>
|
||||
<span className="mr-1 i-ri-add-circle-fill size-3.5" />
|
||||
{t(($) => $['modelProvider.addModel'], { ns: 'common' })}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+171
-1
@@ -1,9 +1,14 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider } from '../../../declarations'
|
||||
import type { CredentialPanelState } from '../../use-credential-panel-state'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../../declarations'
|
||||
import ModelAuthDropdown from '../index'
|
||||
|
||||
const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui)
|
||||
|
||||
vi.mock('../../../model-auth/hooks', () => ({
|
||||
useAuth: () => ({
|
||||
openConfirmDelete: vi.fn(),
|
||||
@@ -190,6 +195,64 @@ describe('ModelAuthDropdown', () => {
|
||||
})
|
||||
|
||||
describe('Popover behavior', () => {
|
||||
it('should keep the popover open and allow retrying when loading a summary detail fails', async () => {
|
||||
const providerSummary = {
|
||||
provider: 'test',
|
||||
plugin_id: 'test-plugin',
|
||||
label: { en_US: 'Test', zh_Hans: 'Test' },
|
||||
supported_model_types: ['llm'],
|
||||
configurate_methods: [],
|
||||
preferred_provider_type: 'system',
|
||||
is_configured: true,
|
||||
custom_configuration: {
|
||||
status: 'active',
|
||||
has_custom_models: false,
|
||||
available_credentials: [],
|
||||
current_credential_usable: false,
|
||||
},
|
||||
system_configuration: { enabled: true },
|
||||
} satisfies ModelProviderSummaryResponse
|
||||
const fullProvider = createProvider()
|
||||
render(
|
||||
<ModelAuthDropdown
|
||||
provider={providerSummary}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={onChangePriority}
|
||||
/>,
|
||||
)
|
||||
let resolveFirstRequest: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi.mocked(globalThis.fetch)
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirstRequest = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /addApiKey/i }))
|
||||
|
||||
expect(await screen.findByRole('status')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
resolveFirstRequest?.(new Response(null, { status: 500 }))
|
||||
})
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('common.api.actionFailed')
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: [fullProvider] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.modelProvider.card.noApiKeysTitle')).toBeInTheDocument()
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should open popover on button click and show dropdown content', async () => {
|
||||
render(
|
||||
<ModelAuthDropdown
|
||||
@@ -213,5 +276,112 @@ describe('ModelAuthDropdown', () => {
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should load provider detail on first click and reuse it on reopen', async () => {
|
||||
const fullProvider = createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||
current_credential_id: 'c1',
|
||||
current_credential_name: 'Key 1',
|
||||
},
|
||||
})
|
||||
const providerSummary = {
|
||||
provider: 'test',
|
||||
is_configured: true,
|
||||
custom_configuration: {
|
||||
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||
current_credential_id: 'c1',
|
||||
current_credential_name: 'Key 1',
|
||||
current_credential_usable: true,
|
||||
},
|
||||
} as unknown as ModelProviderSummaryResponse
|
||||
const rendered = render(
|
||||
<ModelAuthDropdown
|
||||
provider={providerSummary}
|
||||
state={createState({ hasCredentials: true, variant: 'api-active' })}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={onChangePriority}
|
||||
/>,
|
||||
)
|
||||
const fetchQuery = vi
|
||||
.spyOn(rendered.queryClient, 'fetchQuery')
|
||||
.mockImplementation(async () => {
|
||||
const response = { data: [fullProvider] }
|
||||
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
|
||||
return response
|
||||
})
|
||||
const trigger = screen.getByRole('button', { name: /config/i })
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render updated provider detail from the query cache', async () => {
|
||||
const providerSummary = {
|
||||
provider: 'test',
|
||||
is_configured: true,
|
||||
custom_configuration: {
|
||||
available_credentials: [],
|
||||
current_credential_usable: true,
|
||||
},
|
||||
} as unknown as ModelProviderSummaryResponse
|
||||
const firstProvider = createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||
current_credential_id: 'c1',
|
||||
current_credential_name: 'Key 1',
|
||||
},
|
||||
})
|
||||
const nextProvider = createProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
available_credentials: [{ credential_id: 'c2', credential_name: 'Key 2' }],
|
||||
current_credential_id: 'c2',
|
||||
current_credential_name: 'Key 2',
|
||||
},
|
||||
})
|
||||
const rendered = render(
|
||||
<ModelAuthDropdown
|
||||
provider={providerSummary}
|
||||
state={createState({ hasCredentials: true, variant: 'api-active' })}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={onChangePriority}
|
||||
/>,
|
||||
)
|
||||
vi.spyOn(rendered.queryClient, 'fetchQuery').mockImplementation(async () => {
|
||||
const response = { data: [firstProvider] }
|
||||
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
|
||||
return response
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /config/i }))
|
||||
await waitFor(() => expect(screen.getByText('Key 1')).toBeInTheDocument())
|
||||
|
||||
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
|
||||
data: [nextProvider],
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key 2')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+18
-16
@@ -1,29 +1,26 @@
|
||||
import type { ModelProviderCreditsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ICurrentWorkspace } from '@/models/common'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import CreditsExhaustedAlert from './credits-exhausted-alert'
|
||||
|
||||
const baseWorkspace: ICurrentWorkspace = {
|
||||
id: 'ws-1',
|
||||
name: 'Test Workspace',
|
||||
plan: 'sandbox',
|
||||
status: 'normal',
|
||||
created_at: Date.now(),
|
||||
role: 'owner',
|
||||
providers: [],
|
||||
trial_credits: 200,
|
||||
trial_credits_used: 200,
|
||||
trial_credits_exhausted_at: 0,
|
||||
const baseCredits: ModelProviderCreditsResponse = {
|
||||
pool_type: 'trial',
|
||||
quota_limit: 200,
|
||||
quota_used: 200,
|
||||
remaining_credits: 0,
|
||||
is_unlimited: false,
|
||||
is_exhausted: true,
|
||||
exhausted_at: 0,
|
||||
next_credit_reset_date: Date.now() + 86400000,
|
||||
}
|
||||
|
||||
function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) {
|
||||
function createSeededQueryClient(overrides?: Partial<ModelProviderCreditsResponse>) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
|
||||
})
|
||||
qc.setQueryData(consoleQuery.workspaces.current.post.queryKey(), {
|
||||
...baseWorkspace,
|
||||
qc.setQueryData(consoleQuery.workspaces.current.modelProviders.credits.get.queryKey(), {
|
||||
...baseCredits,
|
||||
...overrides,
|
||||
})
|
||||
return qc
|
||||
@@ -74,7 +71,12 @@ export const PartialUsage: Story = {
|
||||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider
|
||||
client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })}
|
||||
client={createSeededQueryClient({
|
||||
quota_limit: 500,
|
||||
quota_used: 480,
|
||||
remaining_credits: 20,
|
||||
is_exhausted: false,
|
||||
})}
|
||||
>
|
||||
<div className="w-[320px]">
|
||||
<Story />
|
||||
|
||||
+66
-10
@@ -1,14 +1,16 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
|
||||
import type { CredentialPanelState } from '../use-credential-panel-state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLazyModelProviderDetail } from '../../hooks'
|
||||
import { getButtonConfig } from './button-config'
|
||||
import DropdownContent from './dropdown-content'
|
||||
|
||||
type ModelAuthDropdownProps = {
|
||||
provider: ModelProvider
|
||||
provider: ModelProviderSummaryResponse | ModelProvider
|
||||
state: CredentialPanelState
|
||||
isChangingPriority: boolean
|
||||
onChangePriority: (key: PreferredProviderTypeEnum) => void
|
||||
@@ -22,13 +24,41 @@ function ModelAuthDropdown({
|
||||
}: ModelAuthDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isProviderDetailError, setIsProviderDetailError] = useState(false)
|
||||
const isFullProvider = !('is_configured' in provider) || 'provider_credential_schema' in provider
|
||||
const { providerDetail, loadProviderDetail, isProviderDetailEnabled, isLoadingProviderDetail } =
|
||||
useLazyModelProviderDetail(provider.provider)
|
||||
const currentProvider = isFullProvider ? provider : providerDetail
|
||||
|
||||
const handleClose = useCallback(() => setOpen(false), [])
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsProviderDetailError(false)
|
||||
}, [])
|
||||
|
||||
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
|
||||
const loadDetail = useCallback(async () => {
|
||||
setIsProviderDetailError(false)
|
||||
const detail = await loadProviderDetail()
|
||||
if (!detail) setIsProviderDetailError(true)
|
||||
}, [loadProviderDetail])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
|
||||
if (
|
||||
!isFullProvider &&
|
||||
!(currentProvider && isProviderDetailEnabled && !isLoadingProviderDetail)
|
||||
)
|
||||
void loadDetail()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
@@ -36,6 +66,7 @@ function ModelAuthDropdown({
|
||||
size="small"
|
||||
variant={buttonConfig.variant}
|
||||
title={buttonConfig.text}
|
||||
loading={isLoadingProviderDetail}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line size-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{buttonConfig.text}</span>
|
||||
@@ -43,13 +74,38 @@ function ModelAuthDropdown({
|
||||
}
|
||||
/>
|
||||
<PopoverContent placement="bottom-end">
|
||||
<DropdownContent
|
||||
provider={provider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={onChangePriority}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{currentProvider ? (
|
||||
<DropdownContent
|
||||
provider={currentProvider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={onChangePriority}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : isProviderDetailError ? (
|
||||
<div className="flex w-80 flex-col items-start gap-3 p-4" role="alert">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
{t(($) => $['api.actionFailed'], { ns: 'common' })}
|
||||
</span>
|
||||
<Button size="small" variant="secondary" onClick={() => void loadDetail()}>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-80 items-center justify-center gap-2 p-4"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
|
||||
/>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.loading, { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
+8
-1
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
@@ -23,8 +24,10 @@ import ModelName from '../model-name'
|
||||
|
||||
type ModelListItemProps = {
|
||||
model: ModelItem
|
||||
provider: ModelProvider
|
||||
provider: ModelProvider | ModelProviderSummaryResponse
|
||||
isConfigurable: boolean
|
||||
isLoadingLoadBalancing?: boolean
|
||||
isLoadBalancingDisabled?: boolean
|
||||
onChange?: (provider: string) => void
|
||||
onModifyLoadBalancing?: (model: ModelItem) => void
|
||||
}
|
||||
@@ -33,6 +36,8 @@ const ModelListItem = ({
|
||||
model,
|
||||
provider,
|
||||
isConfigurable,
|
||||
isLoadingLoadBalancing,
|
||||
isLoadBalancingDisabled,
|
||||
onChange,
|
||||
onModifyLoadBalancing,
|
||||
}: ModelListItemProps) => {
|
||||
@@ -132,6 +137,8 @@ const ModelListItem = ({
|
||||
[ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status) && (
|
||||
<ConfigModel
|
||||
onClick={() => onModifyLoadBalancing?.(model)}
|
||||
loading={isLoadingLoadBalancing}
|
||||
disabled={isLoadBalancingDisabled}
|
||||
loadBalancingEnabled={model.load_balancing_enabled}
|
||||
loadBalancingInvalid={model.has_invalid_load_balancing_configs}
|
||||
credentialRemoved={model.status === ModelStatusEnum.credentialRemoved}
|
||||
|
||||
+97
-26
@@ -1,30 +1,69 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import type { Credential, ModelItem, ModelProvider } from '../declarations'
|
||||
import type { ModelLoadBalancingModalProps } from './model-load-balancing-modal'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AddCustomModel,
|
||||
ManageCustomModelCredentials,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
import { useLazyModelProviderDetail } from '../hooks'
|
||||
import LazyCustomModelActions from './lazy-custom-model-actions'
|
||||
// import Tab from './tab'
|
||||
import ModelListItem from './model-list-item'
|
||||
|
||||
const ModelLoadBalancingModal = dynamic(() => import('./model-load-balancing-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const ModelLoadBalancingLoadingDialog = ({
|
||||
onClose,
|
||||
}: Pick<ModelLoadBalancingModalProps, 'onClose'>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose?.()}>
|
||||
<DialogContent className="w-160 max-w-none border-none px-8 pt-8 text-left align-middle">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['modelProvider.auth.configModel'], { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2 py-8" role="status" aria-busy="true">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
|
||||
/>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.loading, { ns: 'common' })}
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelListProps = {
|
||||
provider: ModelProvider
|
||||
provider: ModelProvider | ModelProviderSummaryResponse
|
||||
models: ModelItem[]
|
||||
onCollapse: () => void
|
||||
onChange?: (provider: string) => void
|
||||
}
|
||||
|
||||
const getModelKey = (model: ModelItem) => `${model.model}-${model.model_type}-${model.fetch_from}`
|
||||
|
||||
let ModelLoadBalancingModal: ComponentType<ModelLoadBalancingModalProps> | undefined
|
||||
let modelLoadBalancingModalPromise: Promise<void> | undefined
|
||||
|
||||
const loadModelLoadBalancingModal = () => {
|
||||
if (ModelLoadBalancingModal) return Promise.resolve()
|
||||
|
||||
modelLoadBalancingModalPromise ??= import('./model-load-balancing-modal').then(
|
||||
({ default: Modal }) => {
|
||||
ModelLoadBalancingModal = Modal
|
||||
},
|
||||
)
|
||||
|
||||
return modelLoadBalancingModalPromise
|
||||
}
|
||||
|
||||
const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const configurativeMethods = provider.configurate_methods.filter(
|
||||
@@ -35,17 +74,52 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
|
||||
const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
|
||||
const [modelLoadBalancingModalProps, setModelLoadBalancingModalProps] =
|
||||
useState<ModelLoadBalancingModalProps | null>(null)
|
||||
const [loadingModelKey, setLoadingModelKey] = useState<string | null>(null)
|
||||
const [isModelLoadBalancingModalLoading, setIsModelLoadBalancingModalLoading] = useState(false)
|
||||
const { loadProviderDetail } = useLazyModelProviderDetail(provider.provider)
|
||||
const onModifyLoadBalancing = useCallback(
|
||||
(model: ModelItem, credential?: Credential) => {
|
||||
async (model: ModelItem, credential?: Credential) => {
|
||||
if (loadingModelKey) return
|
||||
|
||||
let providerDetail: ModelProvider | undefined
|
||||
if ('is_configured' in provider) {
|
||||
setLoadingModelKey(getModelKey(model))
|
||||
try {
|
||||
providerDetail = await loadProviderDetail()
|
||||
} finally {
|
||||
setLoadingModelKey(null)
|
||||
}
|
||||
} else {
|
||||
providerDetail = provider
|
||||
}
|
||||
|
||||
if (!providerDetail) {
|
||||
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
|
||||
return
|
||||
}
|
||||
|
||||
setModelLoadBalancingModalProps({
|
||||
provider,
|
||||
provider: providerDetail,
|
||||
credential,
|
||||
configurateMethod: model.fetch_from,
|
||||
model,
|
||||
open: true,
|
||||
onSave: onChange,
|
||||
})
|
||||
|
||||
if (ModelLoadBalancingModal) return
|
||||
|
||||
setIsModelLoadBalancingModalLoading(true)
|
||||
try {
|
||||
await loadModelLoadBalancingModal()
|
||||
} catch {
|
||||
setModelLoadBalancingModalProps(null)
|
||||
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
|
||||
} finally {
|
||||
setIsModelLoadBalancingModalLoading(false)
|
||||
}
|
||||
},
|
||||
[provider],
|
||||
[loadingModelKey, loadProviderDetail, onChange, provider, t],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -60,7 +134,7 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
|
||||
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary outline-hidden group-hover:inline-flex focus-visible:inline-flex focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => onCollapse()}
|
||||
>
|
||||
{t(($) => $['modelProvider.modelsNum'], { ns: 'common', num: models.length })}
|
||||
@@ -69,25 +143,20 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
|
||||
</span>
|
||||
{isConfigurable && canConfigureModels && (
|
||||
<div className="flex grow justify-end">
|
||||
<ManageCustomModelCredentials
|
||||
provider={provider}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<AddCustomModel
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={undefined}
|
||||
/>
|
||||
<LazyCustomModelActions provider={provider} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{models.map((model) => (
|
||||
<ModelListItem
|
||||
key={`${model.model}-${model.model_type}-${model.fetch_from}`}
|
||||
key={getModelKey(model)}
|
||||
{...{
|
||||
model,
|
||||
provider,
|
||||
isConfigurable,
|
||||
isLoadingLoadBalancing: loadingModelKey === getModelKey(model),
|
||||
isLoadBalancingDisabled:
|
||||
loadingModelKey !== null && loadingModelKey !== getModelKey(model),
|
||||
onChange,
|
||||
onModifyLoadBalancing,
|
||||
}}
|
||||
@@ -95,11 +164,13 @@ const ModelList: FC<ModelListProps> = ({ provider, models, onCollapse, onChange
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{modelLoadBalancingModalProps && (
|
||||
{isModelLoadBalancingModalLoading && modelLoadBalancingModalProps && (
|
||||
<ModelLoadBalancingLoadingDialog onClose={() => setModelLoadBalancingModalProps(null)} />
|
||||
)}
|
||||
{ModelLoadBalancingModal && modelLoadBalancingModalProps && (
|
||||
<ModelLoadBalancingModal
|
||||
{...modelLoadBalancingModalProps}
|
||||
onClose={() => setModelLoadBalancingModalProps(null)}
|
||||
onSave={onChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+281
-9
@@ -1,9 +1,21 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProviderPluginSummary } from '../index'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
|
||||
@@ -17,24 +29,272 @@ import { PluginSource } from '@/app/components/plugins/types'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { normalizeInstalledPluginDetail } from '@/service/use-plugins'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type Props = Readonly<{
|
||||
type DetailAction = 'version' | 'latest' | 'info' | 'check'
|
||||
|
||||
type Props =
|
||||
| Readonly<{
|
||||
summary: ModelProviderPluginSummary
|
||||
providerLabel: string
|
||||
onUpdate?: () => void
|
||||
detail?: never
|
||||
}>
|
||||
| Readonly<{
|
||||
detail: PluginDetail
|
||||
onUpdate?: () => void
|
||||
summary?: never
|
||||
providerLabel?: never
|
||||
}>
|
||||
|
||||
const pluginSourceMap: Record<ModelProviderPluginSummary['source'], PluginSource> = {
|
||||
github: PluginSource.github,
|
||||
marketplace: PluginSource.marketplace,
|
||||
package: PluginSource.local,
|
||||
remote: PluginSource.debugging,
|
||||
}
|
||||
|
||||
const ProviderCardActions: FC<Props> = (props) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { onUpdate } = props
|
||||
const handlePluginChanged = useCallback(async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.installedIds.get.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.latestVersions.post.key(),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
|
||||
queryClient.invalidateQueries({ queryKey: ['marketplacePlugins'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionPlugins'] }),
|
||||
])
|
||||
onUpdate?.()
|
||||
}, [onUpdate, queryClient])
|
||||
|
||||
if (props.detail) {
|
||||
return (
|
||||
<LoadedProviderCardActions
|
||||
detail={props.detail}
|
||||
onInitialActionHandled={() => {}}
|
||||
onUpdate={handlePluginChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <SummaryProviderCardActions {...props} onUpdate={handlePluginChanged} />
|
||||
}
|
||||
|
||||
type SummaryProps = Extract<Props, { summary: ModelProviderPluginSummary }>
|
||||
|
||||
function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: SummaryProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
|
||||
const [detail, setDetail] = useState<PluginDetail>()
|
||||
const [detailAction, setDetailAction] = useState<DetailAction>()
|
||||
const [loadingAction, setLoadingAction] = useState<DetailAction>()
|
||||
const [showDeleteConfirm, { setTrue: openDeleteConfirm, setFalse: closeDeleteConfirm }] =
|
||||
useBoolean(false)
|
||||
const [deleting, { setTrue: startDeleting, setFalse: finishDeleting }] = useBoolean(false)
|
||||
const source = pluginSourceMap[summary.source]
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const canChangeVersion = canUpdatePlugin && isFromMarketplace
|
||||
const hasNewVersion =
|
||||
isFromMarketplace && !!summary.latestVersion && summary.latestVersion !== summary.version
|
||||
const [author, name] = summary.plugin_id.split('/')
|
||||
const detailUrl =
|
||||
isFromMarketplace && author && name ? getMarketplaceUrl(`/plugins/${author}/${name}`) : ''
|
||||
|
||||
const loadDetail = async (action: DetailAction) => {
|
||||
setLoadingAction(action)
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(
|
||||
consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
|
||||
input: { body: { plugin_ids: [summary.plugin_id] } },
|
||||
}),
|
||||
)
|
||||
const nextDetail = response.plugins[0]
|
||||
if (!nextDetail) return
|
||||
const normalizedDetail = normalizeInstalledPluginDetail(nextDetail)
|
||||
const detailWithLatestVersion =
|
||||
isFromMarketplace && summary.latestVersion && summary.latestUniqueIdentifier
|
||||
? {
|
||||
...normalizedDetail,
|
||||
latest_version: summary.latestVersion,
|
||||
latest_unique_identifier: summary.latestUniqueIdentifier,
|
||||
}
|
||||
: normalizedDetail
|
||||
setDetail(detailWithLatestVersion)
|
||||
setDetailAction(action)
|
||||
} catch {
|
||||
} finally {
|
||||
setLoadingAction(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
startDeleting()
|
||||
try {
|
||||
const response = await uninstallPlugin(summary.installation_id)
|
||||
if (!response.success) return
|
||||
closeDeleteConfirm()
|
||||
await onUpdate?.()
|
||||
} finally {
|
||||
finishDeleting()
|
||||
}
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
return (
|
||||
<LoadedProviderCardActions
|
||||
detail={detail}
|
||||
initialAction={detailAction}
|
||||
onInitialActionHandled={() => setDetailAction(undefined)}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!summary.version && (
|
||||
<>
|
||||
{canChangeVersion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={summary.version}
|
||||
disabled={loadingAction === 'version'}
|
||||
onClick={() => loadDetail('version')}
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer hover:bg-state-base-hover"
|
||||
uppercase={false}
|
||||
text={
|
||||
<>
|
||||
<span>{summary.version}</span>
|
||||
<span className="ml-1 i-ri-arrow-left-right-line size-3" />
|
||||
</>
|
||||
}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Badge
|
||||
uppercase={false}
|
||||
text={<span>{summary.version}</span>}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
)}
|
||||
{source === PluginSource.debugging && (
|
||||
<Badge
|
||||
className="border-state-warning-active bg-state-warning-hover text-text-warning"
|
||||
size="xs"
|
||||
uppercase={false}
|
||||
text={t(($) => $['operation.debugConfig'], { ns: 'appDebug' })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={300}
|
||||
render={
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="h-5!"
|
||||
loading={loadingAction === 'latest'}
|
||||
onClick={() => loadDetail('latest')}
|
||||
>
|
||||
{t(($) => $['detailPanel.operation.update'], { ns: 'plugin' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(($) => $['detailPanel.operation.updateTooltip'], { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={() => loadDetail('info')}
|
||||
onCheckVersion={() => loadDetail('check')}
|
||||
onRemove={openDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
placement="bottom-start"
|
||||
destructiveRemove
|
||||
showCheckVersion={canUpdatePlugin}
|
||||
showRemove={canDeletePlugin}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDeleteConfirm()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['action.delete'], { ns: 'plugin' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['action.deleteContentLeft'], { ns: 'plugin' })}
|
||||
<span className="system-md-semibold text-text-secondary">{providerLabel}</span>
|
||||
{t(($) => $['action.deleteContentRight'], { ns: 'plugin' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={deleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type LoadedProps = Readonly<{
|
||||
detail: PluginDetail
|
||||
onUpdate?: () => void | Promise<void>
|
||||
initialAction?: DetailAction
|
||||
onInitialActionHandled: () => void
|
||||
onUpdate?: () => void
|
||||
}>
|
||||
|
||||
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
||||
function LoadedProviderCardActions({
|
||||
detail,
|
||||
initialAction,
|
||||
onInitialActionHandled,
|
||||
onUpdate,
|
||||
}: LoadedProps) {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const locale = useLocale()
|
||||
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
|
||||
|
||||
const { source, version, latest_version, latest_unique_identifier, meta } = detail
|
||||
const author = detail.declaration?.author ?? ''
|
||||
const name = detail.declaration?.name ?? detail.name
|
||||
const isDebuggingPlugin = source === PluginSource.debugging
|
||||
|
||||
const {
|
||||
modalStates,
|
||||
versionPicker,
|
||||
@@ -43,7 +303,6 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
||||
isFromMarketplace,
|
||||
isFromGitHub,
|
||||
} = useDetailHeaderState(detail)
|
||||
|
||||
const { handleUpdate, handleUpdatedFromMarketplace, handleDelete } = usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
@@ -63,15 +322,28 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
||||
handleUpdate(state.isDowngrade)
|
||||
}
|
||||
|
||||
const handleTriggerLatestUpdate = () => {
|
||||
const handleTriggerLatestUpdate = useCallback(() => {
|
||||
if (isFromMarketplace) {
|
||||
if (!latest_unique_identifier) return
|
||||
versionPicker.setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}
|
||||
}, [handleUpdate, isFromMarketplace, latest_unique_identifier, latest_version, versionPicker])
|
||||
|
||||
const pendingInitialActionRef = useRef(initialAction)
|
||||
useEffect(() => {
|
||||
const pendingAction = pendingInitialActionRef.current
|
||||
if (!pendingAction) return
|
||||
pendingInitialActionRef.current = undefined
|
||||
if (pendingAction === 'version') versionPicker.setIsShow(true)
|
||||
if (pendingAction === 'latest') handleTriggerLatestUpdate()
|
||||
if (pendingAction === 'info') modalStates.showPluginInfo()
|
||||
if (pendingAction === 'check') handleUpdate()
|
||||
onInitialActionHandled()
|
||||
}, [handleTriggerLatestUpdate, handleUpdate, modalStates, onInitialActionHandled, versionPicker])
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (source === PluginSource.github) return meta?.repo ? `https://github.com/${meta.repo}` : ''
|
||||
|
||||
+79
-26
@@ -1,6 +1,7 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { PluginManifestInMarket } from '@/app/components/plugins/types'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
@@ -17,9 +18,9 @@ import InstallFromMarketplace from '@/app/components/plugins/install-plugin/inst
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchManifestFromMarketPlace, fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { PreferredProviderTypeEnum } from '../declarations'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import {
|
||||
MODEL_PROVIDER_QUOTA_GET_PAID,
|
||||
modelNameMap,
|
||||
@@ -69,16 +70,29 @@ const QuotaInfotip: FC<QuotaInfotipProps> = ({ tipText }) => {
|
||||
}
|
||||
|
||||
type QuotaPanelProps = {
|
||||
providers: ModelProvider[]
|
||||
providers: Array<ModelProviderSummaryResponse | ModelProvider>
|
||||
}
|
||||
|
||||
type MarketplacePluginToInstall = {
|
||||
manifest: PluginManifestInMarket
|
||||
uniqueIdentifier: string
|
||||
}
|
||||
|
||||
const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { usedCredits, totalCredits, isExhausted, isLoading, exhaustedAt, nextCreditResetDate } =
|
||||
useTrialCredits()
|
||||
const {
|
||||
usedCredits,
|
||||
totalCredits,
|
||||
isUnlimited,
|
||||
isExhausted,
|
||||
isLoading,
|
||||
exhaustedAt,
|
||||
nextCreditResetDate,
|
||||
} = useTrialCredits()
|
||||
const { data: trialModels = [] } = useQuery(
|
||||
consoleQuery.trialModels.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
@@ -94,8 +108,8 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
[providers],
|
||||
)
|
||||
const { formatMonthDay } = useTimestamp()
|
||||
const { plugins: allPlugins } = useMarketplaceAllPlugins(providers, '')
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null)
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<MarketplacePluginToInstall | null>(null)
|
||||
const [loadingPluginId, setLoadingPluginId] = useState<string | null>(null)
|
||||
const [
|
||||
isShowInstallModal,
|
||||
{ setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace },
|
||||
@@ -105,19 +119,41 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
const selectedPluginIdRef = useRef<string | null>(null)
|
||||
|
||||
const handleIconClick = useCallback(
|
||||
(key: ModelProviderQuotaGetPaid) => {
|
||||
async (key: ModelProviderQuotaGetPaid) => {
|
||||
if (loadingPluginId) return
|
||||
|
||||
const isInstalled = providerMap.get(key)
|
||||
if (!isInstalled && allPlugins && canInstallPlugin) {
|
||||
if (!isInstalled && canInstallPlugin) {
|
||||
const pluginId = providerKeyToPluginId[key]
|
||||
const plugin = allPlugins.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
setSelectedPlugin(plugin)
|
||||
const [org, name] = pluginId.split('/')
|
||||
if (!org || !name) return
|
||||
|
||||
setLoadingPluginId(pluginId)
|
||||
try {
|
||||
const pluginInfo = await fetchPluginInfoFromMarketPlace({ org, name })
|
||||
const uniqueIdentifier = pluginInfo.data.plugin.latest_package_identifier
|
||||
if (!uniqueIdentifier) return
|
||||
const manifest = await fetchManifestFromMarketPlace(uniqueIdentifier)
|
||||
setSelectedPlugin({
|
||||
manifest: {
|
||||
...manifest.data.plugin,
|
||||
org,
|
||||
name,
|
||||
from: 'marketplace',
|
||||
icon: manifest.data.plugin.icon || 'marketplace',
|
||||
},
|
||||
uniqueIdentifier,
|
||||
})
|
||||
selectedPluginIdRef.current = pluginId
|
||||
showInstallFromMarketplace()
|
||||
} catch {
|
||||
// Keep the provider actionable so the user can retry the marketplace request.
|
||||
} finally {
|
||||
setLoadingPluginId(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[allPlugins, canInstallPlugin, providerMap, showInstallFromMarketplace],
|
||||
[canInstallPlugin, loadingPluginId, providerMap, showInstallFromMarketplace],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,16 +202,24 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
<div className="flex h-6 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex shrink-0 items-baseline gap-1">
|
||||
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
|
||||
{formatNumber(usedCredits)}
|
||||
</span>
|
||||
<span className="text-base leading-6 font-normal text-text-tertiary">/</span>
|
||||
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
|
||||
{formatNumber(totalCredits)}
|
||||
</span>
|
||||
<span className={cn('system-md-medium', creditUsageTextClassName)}>
|
||||
{t(($) => $['modelProvider.used'], { ns: 'common' })}
|
||||
</span>
|
||||
{isUnlimited ? (
|
||||
<span className="system-xl-semibold text-text-secondary">
|
||||
{t(($) => $['license.unlimited'], { ns: 'common' })}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
|
||||
{formatNumber(usedCredits)}
|
||||
</span>
|
||||
<span className="text-base leading-6 font-normal text-text-tertiary">/</span>
|
||||
<span className={cn('system-xl-semibold', creditUsageTextClassName)}>
|
||||
{formatNumber(totalCredits)}
|
||||
</span>
|
||||
<span className={cn('system-md-medium', creditUsageTextClassName)}>
|
||||
{t(($) => $['modelProvider.used'], { ns: 'common' })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExhausted && exhaustedAt ? (
|
||||
<>
|
||||
@@ -211,6 +255,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
.filter(({ key }) => trialModels.includes(key))
|
||||
.map(({ key, Icon }) => {
|
||||
const providerType = providerMap.get(key)
|
||||
const isLoadingPlugin = loadingPluginId === providerKeyToPluginId[key]
|
||||
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
|
||||
const getTooltipKey = () => {
|
||||
if (!providerType) return 'modelProvider.card.modelNotSupported'
|
||||
@@ -229,13 +274,21 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-busy={isLoadingPlugin}
|
||||
aria-disabled={isLoadingPlugin}
|
||||
className={cn(
|
||||
'relative size-6 border-0 bg-transparent p-0',
|
||||
'relative size-6 border-0 bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
!providerType && canInstallPlugin && 'cursor-pointer hover:opacity-80',
|
||||
)}
|
||||
onClick={() => handleIconClick(key)}
|
||||
>
|
||||
<Icon className="size-6 rounded-lg" />
|
||||
{isLoadingPlugin && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-1 i-ri-loader-2-line animate-spin text-text-accent"
|
||||
/>
|
||||
)}
|
||||
{!providerType && (
|
||||
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
|
||||
)}
|
||||
@@ -256,8 +309,8 @@ const QuotaPanel: FC<QuotaPanelProps> = ({ providers }) => {
|
||||
currentDifyVersion={currentDifyVersion}
|
||||
>
|
||||
<InstallFromMarketplace
|
||||
manifest={selectedPlugin}
|
||||
uniqueIdentifier={selectedPlugin.latest_package_identifier}
|
||||
manifest={selectedPlugin.manifest}
|
||||
uniqueIdentifier={selectedPlugin.uniqueIdentifier}
|
||||
onClose={hideInstallFromMarketplace}
|
||||
onSuccess={hideInstallFromMarketplace}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -6,7 +7,9 @@ import { consoleQuery } from '@/service/client'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
import { useUpdateModelList, useUpdateModelProviders } from '../hooks'
|
||||
|
||||
export function useChangeProviderPriority(provider: ModelProvider | undefined) {
|
||||
export function useChangeProviderPriority(
|
||||
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const updateModelList = useUpdateModelList()
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
|
||||
@@ -64,7 +65,9 @@ function deriveVariant(
|
||||
return 'api-required-add'
|
||||
}
|
||||
|
||||
export function useCredentialPanelState(provider: ModelProvider | undefined): CredentialPanelState {
|
||||
export function useCredentialPanelState(
|
||||
provider: ModelProvider | ModelProviderSummaryResponse | undefined,
|
||||
): CredentialPanelState {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
|
||||
+11
-19
@@ -1,31 +1,22 @@
|
||||
import type { ModelProviderCreditsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const selectTrialCredits = (workspace: {
|
||||
trial_credits?: number | null
|
||||
trial_credits_used?: number | null
|
||||
trial_credits_exhausted_at?: number | null
|
||||
next_credit_reset_date?: number | null
|
||||
}) => {
|
||||
const totalCredits = Math.max(workspace.trial_credits ?? 0, 0)
|
||||
const rawUsedCredits = workspace.trial_credits_used ?? 0
|
||||
const normalizedUsedCredits = Math.max(rawUsedCredits, 0)
|
||||
const usedCredits = Math.min(normalizedUsedCredits, totalCredits)
|
||||
const credits = Math.max(totalCredits - usedCredits, 0)
|
||||
|
||||
const selectTrialCredits = (creditPool: ModelProviderCreditsResponse) => {
|
||||
return {
|
||||
credits,
|
||||
usedCredits,
|
||||
totalCredits,
|
||||
isExhausted: credits <= 0,
|
||||
exhaustedAt: workspace.trial_credits_exhausted_at ?? undefined,
|
||||
nextCreditResetDate: workspace.next_credit_reset_date ?? undefined,
|
||||
credits: creditPool.remaining_credits ?? 0,
|
||||
usedCredits: creditPool.quota_used ?? 0,
|
||||
totalCredits: creditPool.quota_limit ?? 0,
|
||||
isUnlimited: creditPool.is_unlimited,
|
||||
isExhausted: creditPool.is_exhausted,
|
||||
exhaustedAt: creditPool.exhausted_at ?? undefined,
|
||||
nextCreditResetDate: creditPool.next_credit_reset_date ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTrialCredits = () => {
|
||||
const trialCreditsQuery = useQuery(
|
||||
consoleQuery.workspaces.current.post.queryOptions({
|
||||
consoleQuery.workspaces.current.modelProviders.credits.get.queryOptions({
|
||||
select: selectTrialCredits,
|
||||
}),
|
||||
)
|
||||
@@ -35,6 +26,7 @@ export const useTrialCredits = () => {
|
||||
credits: trialCredits?.credits ?? 0,
|
||||
usedCredits: trialCredits?.usedCredits ?? 0,
|
||||
totalCredits: trialCredits?.totalCredits ?? 0,
|
||||
isUnlimited: trialCredits?.isUnlimited ?? false,
|
||||
isExhausted: trialCredits?.isExhausted ?? true,
|
||||
isLoading: trialCreditsQuery.isPending && !trialCredits,
|
||||
exhaustedAt: trialCredits?.exhaustedAt,
|
||||
|
||||
+15
-4
@@ -1,5 +1,5 @@
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { AnthropicDark, AnthropicLight } from '@/app/components/base/icons/src/public/llm'
|
||||
import { Openai } from '@/app/components/base/icons/src/vender/other'
|
||||
@@ -9,13 +9,16 @@ import { Theme } from '@/types/app'
|
||||
import { useLanguage } from '../hooks'
|
||||
|
||||
type ProviderIconProps = {
|
||||
provider: ModelProvider
|
||||
provider: Pick<
|
||||
ModelProviderSummaryResponse,
|
||||
'provider' | 'icon_small' | 'icon_small_dark' | 'label'
|
||||
>
|
||||
className?: string
|
||||
}
|
||||
const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => {
|
||||
const { theme } = useTheme()
|
||||
const language = useLanguage()
|
||||
const lightIconUrl = renderI18nObject(provider.icon_small, language)
|
||||
const lightIconUrl = provider.icon_small ? renderI18nObject(provider.icon_small, language) : ''
|
||||
const darkIconUrl = provider.icon_small_dark
|
||||
? renderI18nObject(provider.icon_small_dark, language)
|
||||
: ''
|
||||
@@ -41,7 +44,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,7 +1,7 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ModelProvider } from './declarations'
|
||||
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
|
||||
type CreditAwareProvider = Pick<ModelProvider, 'provider' | 'system_configuration'>
|
||||
type CreditAwareProvider = Pick<ModelProviderSummaryResponse, 'provider' | 'system_configuration'>
|
||||
|
||||
export const providerSupportsCredits = (
|
||||
provider: CreditAwareProvider | undefined,
|
||||
|
||||
+40
-3
@@ -1,5 +1,6 @@
|
||||
import type { DefaultModelResponse } from '../../declarations'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ModelTypeEnum } from '../../declarations'
|
||||
@@ -23,6 +24,7 @@ vi.mock('react-i18next', async () => {
|
||||
'modelProvider.ttsModel.tip': 'TTS model tip',
|
||||
'operation.cancel': 'Cancel',
|
||||
'operation.save': 'Save',
|
||||
loading: 'Loading',
|
||||
'actionMsg.modifiedSuccessfully': 'Modified successfully',
|
||||
})
|
||||
})
|
||||
@@ -31,6 +33,7 @@ const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockModelSelectorProps = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
@@ -67,9 +70,7 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useModelList: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useModelList: mockUseModelList,
|
||||
useSystemDefaultModelAndModelList: (defaultModel: DefaultModelResponse | undefined) => [
|
||||
defaultModel || {
|
||||
model: '',
|
||||
@@ -128,6 +129,7 @@ const defaultProps = {
|
||||
describe('SystemModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelList.mockReturnValue({ data: [], isLoading: false })
|
||||
mockModelSelectorProps.length = 0
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config']
|
||||
})
|
||||
@@ -146,6 +148,41 @@ describe('SystemModel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads non-text model lists only after the dialog opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: false })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.rerank, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.speech2text, { enabled: true })
|
||||
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.tts, { enabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading instead of empty model selectors while model lists load', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseModelList.mockReturnValue({ data: [], isLoading: true })
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Mock Model Selector' })).not.toBeInTheDocument()
|
||||
const saveButton = screen.getByRole('button', { name: /save/i })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
await user.click(saveButton)
|
||||
expect(mockUpdateDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable button when loading', () => {
|
||||
render(<SystemModel {...defaultProps} isLoading />)
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
||||
|
||||
+126
-89
@@ -66,10 +66,22 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config')
|
||||
const updateModelList = useUpdateModelList()
|
||||
const invalidateDefaultModel = useInvalidateDefaultModel()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text)
|
||||
const { data: ttsModelList } = useModelList(ModelTypeEnum.tts)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: embeddingModelList, isLoading: isEmbeddingModelListLoading } = useModelList(
|
||||
ModelTypeEnum.textEmbedding,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: rerankModelList, isLoading: isRerankModelListLoading } = useModelList(
|
||||
ModelTypeEnum.rerank,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: speech2textModelList, isLoading: isSpeech2textModelListLoading } = useModelList(
|
||||
ModelTypeEnum.speech2text,
|
||||
{ enabled: open },
|
||||
)
|
||||
const { data: ttsModelList, isLoading: isTTSModelListLoading } = useModelList(ModelTypeEnum.tts, {
|
||||
enabled: open,
|
||||
})
|
||||
const [changedModelTypes, setChangedModelTypes] = useState<ModelTypeEnum[]>([])
|
||||
const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] =
|
||||
useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList)
|
||||
@@ -83,7 +95,12 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
ttsDefaultModel,
|
||||
ttsModelList,
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const isSystemModelListLoading =
|
||||
open &&
|
||||
(isEmbeddingModelListLoading ||
|
||||
isRerankModelListLoading ||
|
||||
isSpeech2textModelListLoading ||
|
||||
isTTSModelListLoading)
|
||||
|
||||
const getCurrentDefaultModelByModelType = (modelType: ModelTypeEnum) => {
|
||||
if (modelType === ModelTypeEnum.textGeneration) return currentTextGenerationDefaultModel
|
||||
@@ -105,6 +122,8 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
setChangedModelTypes([...changedModelTypes, modelType])
|
||||
}
|
||||
const handleSave = async () => {
|
||||
if (isSystemModelListLoading) return
|
||||
|
||||
const res = await updateDefaultModel({
|
||||
url: '/workspaces/current/default-model',
|
||||
body: {
|
||||
@@ -187,91 +206,109 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.systemReasoningModel.key',
|
||||
'modelProvider.systemReasoningModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
|
||||
}
|
||||
/>
|
||||
{isSystemModelListLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t(($) => $.loading, { ns: 'common' })}
|
||||
className="flex h-full min-h-48 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-5 animate-spin text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.systemReasoningModel.key',
|
||||
'modelProvider.systemReasoningModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.embeddingModel.key',
|
||||
'modelProvider.embeddingModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.rerankModel.key',
|
||||
'modelProvider.rerankModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.speechToTextModel.key',
|
||||
'modelProvider.speechToTextModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.speech2text, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.embeddingModel.key',
|
||||
'modelProvider.embeddingModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) =>
|
||||
handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.rerankModel.key', 'modelProvider.rerankModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel(
|
||||
'modelProvider.speechToTextModel.key',
|
||||
'modelProvider.speechToTextModel.tip',
|
||||
)}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderModelLabel('modelProvider.ttsModel.key', 'modelProvider.ttsModel.tip')}
|
||||
<div>
|
||||
<ModelSelector
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={(model) => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-19 shrink-0 items-center justify-end gap-2 px-6 pt-5 pb-6">
|
||||
<Button className="min-w-18" onClick={() => setOpen(false)}>
|
||||
@@ -281,7 +318,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
className="min-w-18"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canManageSystemDefaultModel}
|
||||
disabled={!canManageSystemDefaultModel || isSystemModelListLoading}
|
||||
>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
|
||||
@@ -32,11 +32,6 @@ import {
|
||||
|
||||
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
|
||||
export const providerToPluginId = (providerKey: string): string => {
|
||||
const lastSlash = providerKey.lastIndexOf('/')
|
||||
return lastSlash > 0 ? providerKey.slice(0, lastSlash) : ''
|
||||
}
|
||||
|
||||
export const MODEL_PROVIDER_QUOTA_GET_PAID = [
|
||||
ModelProviderQuotaGetPaid.OPENAI,
|
||||
ModelProviderQuotaGetPaid.ANTHROPIC,
|
||||
@@ -86,7 +81,7 @@ export const sizeFormat = (size: number) => {
|
||||
else return `${remainder}K`
|
||||
}
|
||||
|
||||
export const modelTypeFormat = (modelType: ModelTypeEnum) => {
|
||||
export const modelTypeFormat = (modelType: ModelTypeEnum | ModelType) => {
|
||||
if (modelType === ModelTypeEnum.textEmbedding) return 'TEXT EMBEDDING'
|
||||
|
||||
return modelType.toLocaleUpperCase()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolCategory } from '@/app/components/integrations/routes'
|
||||
import type { ToolsContentInset } from '@/app/components/tools/content-inset'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
@@ -28,14 +28,18 @@ import {
|
||||
useCanManageMCP,
|
||||
useCanManageTools,
|
||||
} from '@/app/components/tools/hooks/use-tool-permissions'
|
||||
import Marketplace from '@/app/components/tools/marketplace'
|
||||
import { BuiltinMarketplacePanel } from '@/app/components/tools/marketplace/builtin-marketplace-panel'
|
||||
import MCPList from '@/app/components/tools/mcp'
|
||||
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 { useToolMarketplacePanel } from './hooks/use-tool-marketplace-panel'
|
||||
import {
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllToolProviders,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useToolProviderCategory } from './hooks/use-tool-provider-category'
|
||||
import ToolProviderCreateAction from './tool-provider-create-action'
|
||||
import { ToolProviderToolbar } from './tool-provider-toolbar'
|
||||
@@ -46,40 +50,7 @@ type ProviderListProps = {
|
||||
layout?: (parts: { body: ReactNode; toolbar: ReactNode }) => ReactNode
|
||||
}
|
||||
|
||||
type BuiltinMarketplacePanelProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
contentInset: ToolsContentInset
|
||||
keywords: string
|
||||
tagFilterValue: string[]
|
||||
}
|
||||
|
||||
const BuiltinMarketplacePanel = ({
|
||||
containerRef,
|
||||
contentInset,
|
||||
keywords,
|
||||
tagFilterValue,
|
||||
}: BuiltinMarketplacePanelProps) => {
|
||||
const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } =
|
||||
useToolMarketplacePanel({
|
||||
containerRef,
|
||||
keywords,
|
||||
tagFilterValue,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={toolListTailRef} />
|
||||
<Marketplace
|
||||
searchPluginText={keywords}
|
||||
filterPluginTags={tagFilterValue}
|
||||
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
marketplaceContext={marketplaceContext}
|
||||
contentInset={contentInset}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const EMPTY_COLLECTIONS: Collection[] = []
|
||||
|
||||
const ProviderList = ({ category, contentInset = 'default', layout }: ProviderListProps) => {
|
||||
// const searchParams = useSearchParams()
|
||||
@@ -119,11 +90,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])
|
||||
@@ -250,10 +235,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" />
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
|
||||
const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn())
|
||||
const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
postMarketplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
getFormattedPlugin: (plugin: unknown) => plugin,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
getMarketplacePluginsByCollectionId,
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { Wrapper, queryClient }
|
||||
}
|
||||
|
||||
describe('useMarketplacePluginsByCollectionId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show loading while the first collection request is pending', async () => {
|
||||
getMarketplacePluginsByCollectionId.mockImplementation(() => new Promise(() => {}))
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { Wrapper } = createWrapper()
|
||||
const { result } = renderHook(
|
||||
() => useMarketplacePluginsByCollectionId('__model-settings-pinned-models'),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMarketplacePluginsByCollectionId).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should retain collection results while refreshing cached data', async () => {
|
||||
let resolveRefresh: (() => void) | undefined
|
||||
getMarketplacePluginsByCollectionId
|
||||
.mockResolvedValueOnce([{ plugin_id: 'cached-plugin', type: 'plugin' }])
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ plugin_id: string; type: string }[]>((resolve) => {
|
||||
resolveRefresh = () => resolve([{ plugin_id: 'refreshed-plugin', type: 'plugin' }])
|
||||
}),
|
||||
)
|
||||
|
||||
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
|
||||
const { Wrapper, queryClient } = createWrapper()
|
||||
const { result } = renderHook(
|
||||
() => useMarketplacePluginsByCollectionId('__model-settings-pinned-models'),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins).toEqual([{ plugin_id: 'cached-plugin', type: 'plugin' }])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionPlugins'] })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMarketplacePluginsByCollectionId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(result.current.plugins).toEqual([{ plugin_id: 'cached-plugin', type: 'plugin' }])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins).toEqual([{ plugin_id: 'refreshed-plugin', type: 'plugin' }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should retain collection results while refreshing cached data', async () => {
|
||||
let resolveRefresh: (() => void) | undefined
|
||||
getMarketplaceCollectionsAndPlugins
|
||||
.mockResolvedValueOnce({
|
||||
marketplaceCollections: [{ id: 'cached-collection' }],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRefresh = () =>
|
||||
resolve({
|
||||
marketplaceCollections: [{ id: 'refreshed-collection' }],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
|
||||
const { Wrapper, queryClient } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), {
|
||||
wrapper: Wrapper,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.queryMarketplaceCollectionsAndPlugins()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.marketplaceCollections).toEqual([{ id: 'cached-collection' }])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplaceCollectionsAndPlugins'] })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMarketplaceCollectionsAndPlugins).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(result.current.marketplaceCollections).toEqual([{ id: 'cached-collection' }])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.marketplaceCollections).toEqual([{ id: 'refreshed-collection' }])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
},
|
||||
[],
|
||||
)
|
||||
const isLoading = !!queryParams && (isFetching || isPending)
|
||||
const isLoading = !!queryParams && (isPending || (isFetching && !data))
|
||||
|
||||
return {
|
||||
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
|
||||
@@ -73,14 +73,14 @@ export const useMarketplacePluginsByCollectionId = (
|
||||
|
||||
return {
|
||||
plugins: data || [],
|
||||
isLoading: !!collectionId && (isFetching || isPending),
|
||||
isLoading: !!collectionId && (isPending || (isFetching && !data)),
|
||||
isSuccess,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated Use useMarketplacePlugins from query.ts instead
|
||||
*/
|
||||
export const useMarketplacePlugins = () => {
|
||||
export const useMarketplacePlugins = (enabled = true) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()
|
||||
|
||||
@@ -150,7 +150,7 @@ export const useMarketplacePlugins = () => {
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: !!queryParams,
|
||||
enabled: enabled && !!queryParams,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
@@ -187,6 +187,7 @@ export const useMarketplacePlugins = () => {
|
||||
: undefined
|
||||
const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined
|
||||
const isPluginsLoading =
|
||||
enabled &&
|
||||
hasQuery &&
|
||||
(marketplacePluginsQuery.isPending ||
|
||||
(marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data))
|
||||
|
||||
@@ -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 mockRemoveFilteredInstalledPluginPageOnUnmount = 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,
|
||||
@@ -34,8 +38,11 @@ vi.mock('@/i18n-config', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
normalizePluginCategoryListLanguage: (locale: string) => locale,
|
||||
useInstalledPluginList: (...args: unknown[]) => mockUseInstalledPluginList(...args),
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
useRemoveFilteredInstalledPluginPageOnUnmount: (...args: unknown[]) =>
|
||||
mockRemoveFilteredInstalledPluginPageOnUnmount(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
@@ -160,7 +167,7 @@ vi.mock('@/app/components/integrations/tool-provider-card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/integrations/hooks/use-tool-marketplace-panel', () => ({
|
||||
vi.mock('@/app/components/tools/marketplace/use-tool-marketplace-panel', () => ({
|
||||
useToolMarketplacePanel: () => ({
|
||||
isMarketplaceArrowVisible: true,
|
||||
marketplaceContext: {},
|
||||
@@ -265,6 +272,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 +298,7 @@ describe('PluginsPanel', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the loading state while the plugin list is pending', () => {
|
||||
@@ -351,23 +371,49 @@ 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,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
pageSize: 30,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
}),
|
||||
)
|
||||
expect(mockUseInstalledPluginList.mock.calls.at(-1)?.[0]).not.toHaveProperty('refetchOnMount')
|
||||
})
|
||||
|
||||
it('configures filtered cache cleanup for an Integration category panel', () => {
|
||||
render(<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />)
|
||||
|
||||
expect(mockRemoveFilteredInstalledPluginPageOnUnmount).toHaveBeenCalledWith(
|
||||
PluginCategoryEnum.tool,
|
||||
30,
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not configure filtered cache cleanup for the standalone Plugin page', () => {
|
||||
render(<PluginsPanel />)
|
||||
|
||||
expect(mockRemoveFilteredInstalledPluginPageOnUnmount).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
30,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
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 +757,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,49 +11,15 @@ 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'
|
||||
import IntegrationsToolProviderCard from '@/app/components/integrations/tool-provider-card'
|
||||
import Marketplace from '@/app/components/tools/marketplace'
|
||||
import { BuiltinMarketplacePanel } from '@/app/components/tools/marketplace/builtin-marketplace-panel'
|
||||
import List from './list'
|
||||
|
||||
type BuiltinMarketplacePanelProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
contentInset: PluginPageContentInset
|
||||
keywords: string
|
||||
tagFilterValue: string[]
|
||||
}
|
||||
|
||||
const BuiltinMarketplacePanel = ({
|
||||
containerRef,
|
||||
contentInset,
|
||||
keywords,
|
||||
tagFilterValue,
|
||||
}: BuiltinMarketplacePanelProps) => {
|
||||
const { isMarketplaceArrowVisible, marketplaceContext, showMarketplacePanel, toolListTailRef } =
|
||||
useToolMarketplacePanel({
|
||||
containerRef,
|
||||
keywords,
|
||||
tagFilterValue,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={toolListTailRef} />
|
||||
<Marketplace
|
||||
searchPluginText={keywords}
|
||||
filterPluginTags={tagFilterValue}
|
||||
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
marketplaceContext={marketplaceContext}
|
||||
contentInset={contentInset}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type PluginsPanelResultsProps = {
|
||||
autoLoadNextPage: boolean
|
||||
canDeletePlugin: boolean
|
||||
canUpdatePlugin: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
@@ -78,6 +44,7 @@ type PluginsPanelResultsProps = {
|
||||
}
|
||||
|
||||
const PluginsPanelResults = ({
|
||||
autoLoadNextPage,
|
||||
canDeletePlugin,
|
||||
canUpdatePlugin,
|
||||
containerRef,
|
||||
@@ -101,6 +68,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 (
|
||||
<ScrollArea
|
||||
@@ -149,11 +152,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,
|
||||
useRemoveFilteredInstalledPluginPageOnUnmount,
|
||||
} from '@/service/use-plugins'
|
||||
import { usePluginsWithLatestVersion } from '../hooks'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { pluginPageContentFrameClassNames, pluginPageContentInsetClassNames } from './content-inset'
|
||||
@@ -26,6 +31,10 @@ 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 INTEGRATION_PLUGIN_STALE_TIME = 5 * 60 * 1000
|
||||
const INTEGRATION_PLUGIN_GC_TIME = 10 * 60 * 1000
|
||||
|
||||
const matchesSearchQuery = (
|
||||
plugin: PluginDetail & { latest_version: string },
|
||||
query: string,
|
||||
@@ -84,6 +93,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 +114,20 @@ const PluginsPanel = ({
|
||||
isFetching,
|
||||
isLastPage,
|
||||
loadNextPage,
|
||||
} = useInstalledPluginList(
|
||||
false,
|
||||
100,
|
||||
fixedCategory
|
||||
? {
|
||||
category: fixedCategory,
|
||||
refetchOnMount: isIntegrationCategoryPage ? 'always' : undefined,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
} = useInstalledPluginList({
|
||||
category: fixedCategory,
|
||||
filters: installedPluginFilters,
|
||||
gcTime: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_GC_TIME : undefined,
|
||||
pageSize: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_PAGE_SIZE : 100,
|
||||
staleTime: isIntegrationCategoryPage ? INTEGRATION_PLUGIN_STALE_TIME : undefined,
|
||||
})
|
||||
const pluginListWithLatestVersion = usePluginsWithLatestVersion(pluginList?.plugins)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
useRemoveFilteredInstalledPluginPageOnUnmount(
|
||||
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 +258,7 @@ const PluginsPanel = ({
|
||||
<>
|
||||
{hasVisiblePlugins || hasVisibleBuiltinTools || hasToolMarketplacePanel ? (
|
||||
<PluginsPanelResults
|
||||
autoLoadNextPage={isIntegrationCategoryPage}
|
||||
containerRef={containerRef}
|
||||
contentFrameClassName={contentFrameClassName}
|
||||
contentInset={contentInset}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FormTypeEnum } from '../base/form/types'
|
||||
import type { CredentialFormSchemaBase } from '../header/account-setting/model-provider-page/declarations'
|
||||
import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types'
|
||||
import type { TypeWithI18N } from '@/app/components/base/form/types'
|
||||
import type { Collection, ToolCredential } from '@/app/components/tools/types'
|
||||
import type { ToolCredential } from '@/app/components/tools/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
|
||||
export enum PluginCategoryEnum {
|
||||
@@ -441,17 +441,6 @@ export type MetaData = {
|
||||
package: string
|
||||
}
|
||||
|
||||
export type InstalledPluginListWithTotalResponse = {
|
||||
plugins: PluginDetail[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type InstalledPluginCategoryListResponse = {
|
||||
plugins: PluginDetail[]
|
||||
builtin_tools: Collection[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type GitHubItemAndMarketPlaceDependency = {
|
||||
type: 'github' | 'marketplace' | 'package'
|
||||
value: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
@@ -980,10 +981,11 @@ describe('update-plugin', () => {
|
||||
expect(onShowChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSelect with correct params when a version is selected', () => {
|
||||
it('should call onSelect with correct params when a version is selected', async () => {
|
||||
// Arrange
|
||||
const onSelect = vi.fn()
|
||||
const onShowChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(
|
||||
@@ -995,12 +997,7 @@ describe('update-plugin', () => {
|
||||
onShowChange={onShowChange}
|
||||
/>,
|
||||
)
|
||||
// Click on version 2.0.0
|
||||
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
||||
const version2Element = versionElements.find((el) => el.textContent === '2.0.0')
|
||||
if (version2Element) {
|
||||
fireEvent.click(version2Element.closest('div[class*="cursor-pointer"]')!)
|
||||
}
|
||||
await user.click(screen.getByRole('button', { name: /2\.0\.0/ }))
|
||||
|
||||
// Assert
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
@@ -1011,9 +1008,10 @@ describe('update-plugin', () => {
|
||||
expect(onShowChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should not call onSelect when clicking on current version', () => {
|
||||
it('should not call onSelect when clicking on current version', async () => {
|
||||
// Arrange
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(
|
||||
@@ -1024,20 +1022,16 @@ describe('update-plugin', () => {
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
// Click on current version 1.0.0
|
||||
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
||||
const version1Element = versionElements.find((el) => el.textContent === '1.0.0')
|
||||
if (version1Element) {
|
||||
fireEvent.click(version1Element.closest('div[class*="cursor"]')!)
|
||||
}
|
||||
await user.click(screen.getByRole('button', { name: /1\.0\.0/ }))
|
||||
|
||||
// Assert
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should indicate downgrade when selecting a lower version', () => {
|
||||
it('should indicate downgrade when selecting a lower version', async () => {
|
||||
// Arrange
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(
|
||||
@@ -1048,12 +1042,7 @@ describe('update-plugin', () => {
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
// Click on version 1.0.0 (downgrade)
|
||||
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
||||
const version1Element = versionElements.find((el) => el.textContent === '1.0.0')
|
||||
if (version1Element) {
|
||||
fireEvent.click(version1Element.closest('div[class*="cursor-pointer"]')!)
|
||||
}
|
||||
await user.click(screen.getByRole('button', { name: /1\.0\.0/ }))
|
||||
|
||||
// Assert
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user