mirror of
https://github.com/langgenius/dify.git
synced 2026-07-01 19:36:52 +08:00
Compare commits
4 Commits
codex/migr
...
fix/api-pl
| Author | SHA1 | Date | |
|---|---|---|---|
| a8857934f5 | |||
| 65a19a118e | |||
| 746e5ff6ca | |||
| d437ad0dfd |
@ -31,7 +31,7 @@ from controllers.console.wraps import (
|
||||
with_current_user_id,
|
||||
)
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallation, PluginInstallationSource
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
@ -309,8 +309,6 @@ class PluginInstallationItemResponse(ResponseModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
name: str
|
||||
installation_id: str
|
||||
tenant_id: str
|
||||
endpoints_setups: int
|
||||
endpoints_active: int
|
||||
@ -320,45 +318,14 @@ class PluginInstallationItemResponse(ResponseModel):
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
version: str
|
||||
latest_version: str
|
||||
latest_unique_identifier: str
|
||||
status: Literal["active", "deleted"]
|
||||
deprecated_reason: str
|
||||
alternative_plugin_id: str
|
||||
checksum: str
|
||||
declaration: PluginDeclarationResponse
|
||||
declaration: Mapping[str, Any]
|
||||
|
||||
|
||||
class PluginInstallationsResponse(ResponseModel):
|
||||
plugins: list[PluginInstallationItemResponse]
|
||||
|
||||
|
||||
def _plugin_installation_response(plugin: PluginInstallation) -> PluginInstallationItemResponse:
|
||||
return PluginInstallationItemResponse(
|
||||
id=plugin.id,
|
||||
created_at=plugin.created_at,
|
||||
updated_at=plugin.updated_at,
|
||||
name=plugin.declaration.name,
|
||||
installation_id=plugin.id,
|
||||
tenant_id=plugin.tenant_id,
|
||||
endpoints_setups=plugin.endpoints_setups,
|
||||
endpoints_active=plugin.endpoints_active,
|
||||
runtime_type=plugin.runtime_type,
|
||||
source=plugin.source,
|
||||
meta=plugin.meta,
|
||||
plugin_id=plugin.plugin_id,
|
||||
plugin_unique_identifier=plugin.plugin_unique_identifier,
|
||||
version=plugin.version,
|
||||
latest_version=plugin.version,
|
||||
latest_unique_identifier=plugin.plugin_unique_identifier,
|
||||
status="active",
|
||||
deprecated_reason="",
|
||||
alternative_plugin_id="",
|
||||
checksum=plugin.checksum,
|
||||
declaration=PluginDeclarationResponse.model_validate(jsonable_encoder(plugin.declaration)),
|
||||
)
|
||||
|
||||
|
||||
class PluginManifestResponse(ResponseModel):
|
||||
manifest: Any
|
||||
|
||||
@ -621,10 +588,7 @@ class PluginListInstallationsFromIdsApi(Resource):
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
return dump_response(
|
||||
PluginInstallationsResponse,
|
||||
{"plugins": [_plugin_installation_response(plugin) for plugin in plugins]},
|
||||
)
|
||||
return jsonable_encoder({"plugins": plugins})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/icon")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||
|
||||
@ -32,9 +32,7 @@ class MarketplacePluginDeclaration(BaseModel):
|
||||
latest_package_identifier: str = Field(
|
||||
..., description="Unique identifier for the latest package release of the plugin"
|
||||
)
|
||||
status: Literal["active", "deleted"] = Field(
|
||||
..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`"
|
||||
)
|
||||
status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`")
|
||||
deprecated_reason: str = Field(
|
||||
..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)"
|
||||
)
|
||||
|
||||
@ -14,9 +14,10 @@ metadata.
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from mimetypes import guess_type
|
||||
from typing import Any, ClassVar, Literal
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from redis import RedisError
|
||||
@ -68,14 +69,18 @@ logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
|
||||
|
||||
class PluginService:
|
||||
_plugin_model_providers_memory_cache: ClassVar[dict[str, tuple[int, float, tuple[ProviderEntity, ...]]]] = {}
|
||||
class _RedisLock(Protocol):
|
||||
def acquire(self, *, blocking: bool = True, blocking_timeout: float | None = None) -> bool: ...
|
||||
|
||||
def release(self) -> None: ...
|
||||
|
||||
|
||||
class PluginService:
|
||||
class LatestPluginCache(BaseModel):
|
||||
plugin_id: str
|
||||
version: str
|
||||
unique_identifier: str
|
||||
status: Literal["active", "deleted"]
|
||||
status: str
|
||||
deprecated_reason: str
|
||||
alternative_plugin_id: str
|
||||
|
||||
@ -137,10 +142,6 @@ class PluginService:
|
||||
declaration.provider_name = cls._get_provider_short_name_alias(provider)
|
||||
return declaration
|
||||
|
||||
@classmethod
|
||||
def _copy_provider_entities(cls, providers: Sequence[ProviderEntity]) -> tuple[ProviderEntity, ...]:
|
||||
return tuple(provider.model_copy(deep=True) for provider in providers)
|
||||
|
||||
@classmethod
|
||||
def _load_plugin_model_providers_generation(cls, tenant_id: str) -> int | None:
|
||||
cache_key = cls._get_plugin_model_providers_generation_cache_key(tenant_id)
|
||||
@ -171,63 +172,14 @@ class PluginService:
|
||||
)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _load_in_memory_plugin_model_providers(
|
||||
cls, memory_cache_key: str, generation: int
|
||||
) -> tuple[ProviderEntity, ...] | None:
|
||||
cached_entry = cls._plugin_model_providers_memory_cache.get(memory_cache_key)
|
||||
if cached_entry is None:
|
||||
return None
|
||||
|
||||
cached_generation, expires_at, providers = cached_entry
|
||||
if cached_generation != generation or time.monotonic() >= expires_at:
|
||||
cls._plugin_model_providers_memory_cache.pop(memory_cache_key, None)
|
||||
return None
|
||||
|
||||
return cls._copy_provider_entities(providers)
|
||||
|
||||
@classmethod
|
||||
def _store_in_memory_plugin_model_providers(
|
||||
cls, memory_cache_key: str, generation: int, providers: Sequence[ProviderEntity]
|
||||
) -> None:
|
||||
ttl = dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL
|
||||
if ttl <= 0:
|
||||
cls._plugin_model_providers_memory_cache.pop(memory_cache_key, None)
|
||||
return
|
||||
|
||||
cls._plugin_model_providers_memory_cache[memory_cache_key] = (
|
||||
generation,
|
||||
time.monotonic() + ttl,
|
||||
cls._copy_provider_entities(providers),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _load_cached_plugin_model_providers(
|
||||
cls, tenant_id: str, *, client: PluginModelClient | None = None
|
||||
) -> tuple[ProviderEntity, ...] | None:
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
|
||||
return cached_providers
|
||||
|
||||
@classmethod
|
||||
def _load_cached_plugin_model_providers_for_generation(
|
||||
cls, tenant_id: str, generation: int | None
|
||||
) -> tuple[tuple[ProviderEntity, ...] | None, bool]:
|
||||
if generation is not None:
|
||||
in_memory_cached_providers = cls._load_in_memory_plugin_model_providers(tenant_id, generation)
|
||||
if in_memory_cached_providers is not None:
|
||||
return in_memory_cached_providers, True
|
||||
|
||||
if generation is None:
|
||||
return None, False
|
||||
|
||||
cache_keys = []
|
||||
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id, generation))
|
||||
if generation == 0:
|
||||
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id))
|
||||
|
||||
if not cache_keys:
|
||||
return None, True
|
||||
cache_keys = [cls._get_plugin_model_providers_cache_key(tenant_id, generation)]
|
||||
|
||||
try:
|
||||
cached_provider_entries = redis_client.mget(cache_keys)
|
||||
@ -248,8 +200,6 @@ class PluginService:
|
||||
|
||||
try:
|
||||
providers = tuple(_provider_entities_adapter.validate_json(cached_providers))
|
||||
if generation is not None:
|
||||
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
|
||||
return providers, True
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
logger.warning(
|
||||
@ -275,58 +225,92 @@ class PluginService:
|
||||
) -> None:
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id, generation)
|
||||
try:
|
||||
payload = _provider_entities_adapter.dump_json(list(providers)).decode("utf-8")
|
||||
payload = _provider_entities_adapter.dump_json(list(providers))
|
||||
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, payload)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _try_acquire_plugin_model_providers_lock(cls, tenant_id: str, generation: int) -> tuple[Any | None, bool]:
|
||||
@contextmanager
|
||||
def _plugin_model_providers_refresh_lock(
|
||||
cls, tenant_id: str, generation: int, *, wait_timeout: float
|
||||
) -> Iterator[bool]:
|
||||
lock_key = cls._get_plugin_model_providers_lock_key(tenant_id, generation)
|
||||
try:
|
||||
lock = redis_client.lock(lock_key, timeout=cls.PLUGIN_MODEL_PROVIDERS_LOCK_TTL, blocking=False)
|
||||
acquired = lock.acquire(blocking=False)
|
||||
refresh_lock: _RedisLock = redis_client.lock(
|
||||
lock_key,
|
||||
timeout=cls.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
sleep=cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning(
|
||||
"Failed to create plugin model providers refresh lock for tenant %s.",
|
||||
tenant_id,
|
||||
exc_info=True,
|
||||
)
|
||||
yield False
|
||||
return
|
||||
|
||||
try:
|
||||
lock_acquired = refresh_lock.acquire(blocking=True, blocking_timeout=wait_timeout)
|
||||
except LockError:
|
||||
logger.warning(
|
||||
"Provider refresh lock timed out; direct daemon fallback. tenant_id=%s generation=%s",
|
||||
tenant_id,
|
||||
generation,
|
||||
exc_info=True,
|
||||
)
|
||||
yield False
|
||||
return
|
||||
except (RedisError, RuntimeError):
|
||||
# Redis failures should not block provider discovery; callers fetch directly from the daemon.
|
||||
logger.warning(
|
||||
"Failed to acquire plugin model providers refresh lock for tenant %s.",
|
||||
tenant_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None, False
|
||||
yield False
|
||||
return
|
||||
|
||||
if not acquired:
|
||||
return None, True
|
||||
|
||||
return lock, True
|
||||
|
||||
@classmethod
|
||||
def _release_plugin_model_providers_lock(cls, tenant_id: str, lock: Any) -> None:
|
||||
try:
|
||||
lock.release()
|
||||
except (LockError, RedisError, RuntimeError):
|
||||
if not lock_acquired:
|
||||
logger.warning(
|
||||
"Failed to release plugin model providers refresh lock for tenant %s.",
|
||||
"Provider refresh lock timed out; direct daemon fallback. tenant_id=%s generation=%s",
|
||||
tenant_id,
|
||||
exc_info=True,
|
||||
generation,
|
||||
)
|
||||
yield False
|
||||
return
|
||||
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
try:
|
||||
refresh_lock.release()
|
||||
except (LockError, RedisError, RuntimeError):
|
||||
# Release failures must not hide the daemon result or the original exception.
|
||||
logger.warning(
|
||||
"Failed to release plugin model providers refresh lock for tenant %s generation %s.",
|
||||
tenant_id,
|
||||
generation,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _wait_for_plugin_model_providers_refresh(
|
||||
cls, tenant_id: str, *, client: PluginModelClient | None = None
|
||||
) -> tuple[ProviderEntity, ...] | None:
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL)
|
||||
cached_providers = cls._load_cached_plugin_model_providers(tenant_id, client=client)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
|
||||
return None
|
||||
def _fetch_and_cache_plugin_model_providers(
|
||||
cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None and generation == refresh_generation:
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
return providers
|
||||
|
||||
@classmethod
|
||||
def invalidate_plugin_model_providers_cache(cls, tenant_id: str) -> None:
|
||||
"""Invalidate tenant-scoped provider metadata across Redis and worker-local mirrors."""
|
||||
cls._plugin_model_providers_memory_cache.pop(tenant_id, None)
|
||||
"""Invalidate tenant-scoped provider metadata stored in Redis."""
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id)
|
||||
generation_key = cls._get_plugin_model_providers_generation_cache_key(tenant_id)
|
||||
try:
|
||||
@ -348,38 +332,68 @@ class PluginService:
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
cached_providers, cache_available = cls._load_cached_plugin_model_providers_for_generation(
|
||||
tenant_id, generation
|
||||
)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
|
||||
refresh_lock: Any | None = None
|
||||
refresh_generation = generation
|
||||
if generation is not None and cache_available:
|
||||
lock_wait_deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_TTL
|
||||
while time.monotonic() < lock_wait_deadline:
|
||||
refresh_lock, lock_available = cls._try_acquire_plugin_model_providers_lock(tenant_id, generation)
|
||||
if refresh_lock is not None or not lock_available:
|
||||
break
|
||||
refreshed_providers = cls._wait_for_plugin_model_providers_refresh(tenant_id, client=client)
|
||||
if refreshed_providers is not None:
|
||||
return refreshed_providers
|
||||
|
||||
model_client = client or PluginModelClient()
|
||||
try:
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
while True:
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None and generation == refresh_generation:
|
||||
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
return providers
|
||||
finally:
|
||||
if refresh_lock is not None:
|
||||
cls._release_plugin_model_providers_lock(tenant_id, refresh_lock)
|
||||
cached_providers, cache_available = cls._load_cached_plugin_model_providers_for_generation(
|
||||
tenant_id, generation
|
||||
)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
|
||||
if generation is None or not cache_available:
|
||||
return cls._fetch_and_cache_plugin_model_providers(
|
||||
tenant_id,
|
||||
client,
|
||||
refresh_generation=generation,
|
||||
)
|
||||
|
||||
wait_timeout = deadline - time.monotonic()
|
||||
if wait_timeout < 0:
|
||||
logger.warning(
|
||||
"Provider refresh lock timed out; direct daemon fallback. tenant_id=%s generation=%s",
|
||||
tenant_id,
|
||||
generation,
|
||||
)
|
||||
return cls._fetch_and_cache_plugin_model_providers(
|
||||
tenant_id,
|
||||
client,
|
||||
refresh_generation=generation,
|
||||
)
|
||||
|
||||
with cls._plugin_model_providers_refresh_lock(
|
||||
tenant_id,
|
||||
generation,
|
||||
wait_timeout=wait_timeout,
|
||||
) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
return cls._fetch_and_cache_plugin_model_providers(
|
||||
tenant_id,
|
||||
client,
|
||||
refresh_generation=generation,
|
||||
)
|
||||
|
||||
latest_generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
cached_providers, cache_available = cls._load_cached_plugin_model_providers_for_generation(
|
||||
tenant_id, latest_generation
|
||||
)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
if latest_generation is None or not cache_available:
|
||||
return cls._fetch_and_cache_plugin_model_providers(
|
||||
tenant_id,
|
||||
client,
|
||||
refresh_generation=latest_generation,
|
||||
)
|
||||
if latest_generation != generation:
|
||||
continue
|
||||
|
||||
return cls._fetch_and_cache_plugin_model_providers(
|
||||
tenant_id,
|
||||
client,
|
||||
refresh_generation=generation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def fetch_latest_plugin_version(plugin_ids: Sequence[str]) -> Mapping[str, LatestPluginCache | None]:
|
||||
|
||||
@ -18093,7 +18093,7 @@ Enum class for large language model mode.
|
||||
| alternative_plugin_id | string | | Yes |
|
||||
| deprecated_reason | string | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| status | string, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
|
||||
| status | string | | Yes |
|
||||
| unique_identifier | string | | Yes |
|
||||
| version | string | | Yes |
|
||||
|
||||
@ -19588,24 +19588,17 @@ Shared permission levels for resources (datasets, credentials, etc.)
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| alternative_plugin_id | string | | Yes |
|
||||
| checksum | string | | Yes |
|
||||
| created_at | dateTime | | Yes |
|
||||
| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes |
|
||||
| deprecated_reason | string | | Yes |
|
||||
| declaration | object | | Yes |
|
||||
| endpoints_active | integer | | Yes |
|
||||
| endpoints_setups | integer | | Yes |
|
||||
| id | string | | Yes |
|
||||
| installation_id | string | | Yes |
|
||||
| latest_unique_identifier | string | | Yes |
|
||||
| latest_version | string | | Yes |
|
||||
| meta | object | | Yes |
|
||||
| name | string | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| plugin_unique_identifier | string | | Yes |
|
||||
| runtime_type | string | | Yes |
|
||||
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
|
||||
| status | string, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
|
||||
| tenant_id | string | | Yes |
|
||||
| updated_at | dateTime | | Yes |
|
||||
| version | string | | Yes |
|
||||
|
||||
@ -42,7 +42,6 @@ from controllers.console.workspace.plugin import (
|
||||
PluginUploadFromGithubApi,
|
||||
PluginUploadFromPkgApi,
|
||||
)
|
||||
from core.plugin.entities.plugin import PluginInstallation
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
|
||||
|
||||
@ -479,19 +478,12 @@ class TestPluginListInstallationsFromIdsApi:
|
||||
app.test_request_context("/", json=payload),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_installations_from_ids",
|
||||
return_value=[PluginInstallation.model_validate(_plugin_category_list_item())],
|
||||
return_value=[{"id": "p1"}],
|
||||
),
|
||||
):
|
||||
result = method(api, "t1")
|
||||
|
||||
assert result["plugins"][0]["id"] == "entity-1"
|
||||
assert result["plugins"][0]["name"] == "test-plugin"
|
||||
assert result["plugins"][0]["installation_id"] == "entity-1"
|
||||
assert result["plugins"][0]["latest_version"] == "1.0.0"
|
||||
assert result["plugins"][0]["latest_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
|
||||
assert result["plugins"][0]["status"] == "active"
|
||||
assert result["plugins"][0]["deprecated_reason"] == ""
|
||||
assert result["plugins"][0]["alternative_plugin_id"] == ""
|
||||
assert "plugins" in result
|
||||
|
||||
def test_daemon_error(self, app: Flask):
|
||||
api = PluginListInstallationsFromIdsApi()
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch, sentinel
|
||||
from unittest.mock import MagicMock, Mock, patch, sentinel
|
||||
|
||||
import pytest
|
||||
|
||||
@ -44,7 +44,13 @@ class _FakeRedis:
|
||||
def delete(self, key: str) -> None:
|
||||
self._values.pop(key, None)
|
||||
|
||||
def lock(self, key: str, *, timeout: int, blocking: bool) -> "_FakeRedisLock":
|
||||
def lock(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
timeout: int,
|
||||
sleep: float,
|
||||
) -> "_FakeRedisLock":
|
||||
return _FakeRedisLock(self, key)
|
||||
|
||||
|
||||
@ -54,7 +60,7 @@ class _FakeRedisLock:
|
||||
self._key = key
|
||||
self._acquired = False
|
||||
|
||||
def acquire(self, *, blocking: bool) -> bool:
|
||||
def acquire(self, *, blocking: bool = True, blocking_timeout: float | None = None) -> bool:
|
||||
if self._key in self._redis._values:
|
||||
return False
|
||||
|
||||
@ -68,13 +74,6 @@ class _FakeRedisLock:
|
||||
self._acquired = False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_plugin_model_provider_memory_cache() -> None:
|
||||
PluginService._plugin_model_providers_memory_cache.clear()
|
||||
yield
|
||||
PluginService._plugin_model_providers_memory_cache.clear()
|
||||
|
||||
|
||||
def _build_model_schema() -> AIModelEntity:
|
||||
return AIModelEntity(
|
||||
model="gpt-4o-mini",
|
||||
@ -436,10 +435,10 @@ class TestPluginModelRuntime:
|
||||
"redis_client",
|
||||
SimpleNamespace(
|
||||
get=Mock(return_value=None),
|
||||
mget=Mock(return_value=[None, None]),
|
||||
mget=Mock(return_value=[None]),
|
||||
delete=Mock(),
|
||||
setex=Mock(),
|
||||
lock=Mock(return_value=SimpleNamespace(acquire=Mock(return_value=True), release=Mock())),
|
||||
lock=Mock(return_value=MagicMock()),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 0)
|
||||
|
||||
@ -14,15 +14,6 @@ from graphon.model_runtime.entities.provider_entities import ConfigurateMethod,
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_plugin_model_provider_memory_cache() -> None:
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService._plugin_model_providers_memory_cache.clear()
|
||||
yield
|
||||
PluginService._plugin_model_providers_memory_cache.clear()
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
@ -140,14 +131,13 @@ class TestPluginModelProviderCache:
|
||||
def test_fetch_plugin_model_providers_returns_cached_provider_without_calling_daemon(self) -> None:
|
||||
"""A valid tenant cache entry is reused across runtime calls without plugin daemon access."""
|
||||
cached_provider = _build_provider_entity()
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider])
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [cached_payload, None]
|
||||
redis_client.mget.return_value = [cached_payload]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
@ -158,19 +148,18 @@ class TestPluginModelProviderCache:
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
redis_client.get.assert_called_once_with(generation_key)
|
||||
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
|
||||
redis_client.mget.assert_called_once_with([cache_key])
|
||||
|
||||
def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None:
|
||||
"""Invalid generation-scoped cache payloads are removed before falling back to the daemon."""
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
):
|
||||
redis_client.get.side_effect = [None, None]
|
||||
redis_client.mget.return_value = ["not-json", None]
|
||||
redis_client.get.side_effect = [None, None, None]
|
||||
redis_client.mget.side_effect = [["not-json"], [None]]
|
||||
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
@ -184,8 +173,11 @@ class TestPluginModelProviderCache:
|
||||
assert redis_client.setex.call_args.args[0] == cache_key
|
||||
assert redis_client.setex.call_args.args[1] == 86400
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
redis_client.get.assert_has_calls([call(generation_key), call(generation_key)])
|
||||
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
|
||||
redis_client.get.assert_has_calls([call(generation_key), call(generation_key), call(generation_key)])
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([cache_key]),
|
||||
call([cache_key]),
|
||||
]
|
||||
|
||||
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
||||
"""Redis read failures do not block provider discovery for the tenant."""
|
||||
@ -204,7 +196,6 @@ class TestPluginModelProviderCache:
|
||||
def test_fetch_plugin_model_providers_refetches_when_cached_payload_batch_read_fails(self) -> None:
|
||||
"""Redis mget failures do not block provider discovery for the tenant."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.side_effect = RedisError("redis unavailable")
|
||||
@ -216,14 +207,14 @@ class TestPluginModelProviderCache:
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
|
||||
redis_client.mget.assert_called_once_with([cache_key])
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_returns_fresh_result_when_cache_write_fails(self) -> None:
|
||||
"""Redis write failures are non-fatal after fresh provider data has been fetched."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.setex.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
@ -238,17 +229,15 @@ class TestPluginModelProviderCache:
|
||||
def test_fetch_plugin_model_providers_waits_for_concurrent_refresh_cache_fill(self) -> None:
|
||||
"""A cache miss waits for the active tenant refresh instead of stampeding the daemon."""
|
||||
cached_provider = _build_provider_entity()
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider])
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.sleep") as sleep,
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.side_effect = [[None, None], [cached_payload, None]]
|
||||
redis_client.lock.return_value.acquire.return_value = False
|
||||
redis_client.mget.side_effect = [[None], [cached_payload]]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
||||
|
||||
@ -259,51 +248,31 @@ class TestPluginModelProviderCache:
|
||||
redis_client.lock.assert_called_once_with(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
blocking=False,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
)
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(
|
||||
blocking=True,
|
||||
blocking_timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT,
|
||||
)
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=False)
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([cache_key, legacy_cache_key]),
|
||||
call([cache_key, legacy_cache_key]),
|
||||
call([cache_key]),
|
||||
call([cache_key]),
|
||||
]
|
||||
sleep.assert_called()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_retries_lock_after_wait_timeout(self) -> None:
|
||||
"""Only a lock owner should refresh the daemon when the first refresh takes too long."""
|
||||
def test_fetch_plugin_model_providers_falls_back_when_refresh_lock_wait_times_out(self) -> None:
|
||||
"""A request should stop waiting and fetch directly instead of surfacing lock contention."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.sleep"),
|
||||
patch(f"{MODULE}.PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT", 0),
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.lock.return_value.acquire.side_effect = [False, True]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert redis_client.lock.return_value.acquire.call_args_list == [
|
||||
call(blocking=False),
|
||||
call(blocking=False),
|
||||
]
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.lock.return_value.release.assert_called_once_with()
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_after_store(self) -> None:
|
||||
"""The refresh owner releases only its token after storing provider metadata."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.lock.return_value.acquire.return_value = True
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.lock.return_value.acquire.return_value = False
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
@ -314,15 +283,220 @@ class TestPluginModelProviderCache:
|
||||
redis_client.lock.assert_called_once_with(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
blocking=False,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
)
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=False)
|
||||
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
|
||||
redis_client.lock.return_value.release.assert_called_once_with()
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=True, blocking_timeout=0)
|
||||
redis_client.lock.return_value.release.assert_not_called()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == cache_key
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_restarts_lock_path_after_generation_changes(self) -> None:
|
||||
"""Waiters re-read provider generation before trying to become the next refresh owner."""
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
stale_cache_key = _provider_cache_key("tenant-1", 0)
|
||||
new_cache_key = _provider_cache_key("tenant-1", 1)
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", side_effect=[100.0, 100.0, 100.5, 101.0]),
|
||||
):
|
||||
redis_client.get.side_effect = [None, b"1", b"1", b"1", b"1"]
|
||||
redis_client.mget.side_effect = [[None], [None], [None], [None]]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert redis_client.get.call_args_list == [
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
]
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([stale_cache_key]),
|
||||
call([new_cache_key]),
|
||||
call([new_cache_key]),
|
||||
call([new_cache_key]),
|
||||
]
|
||||
assert redis_client.lock.call_args_list == [
|
||||
call(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
),
|
||||
call(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 1),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
),
|
||||
]
|
||||
assert redis_client.lock.return_value.acquire.call_args_list == [
|
||||
call(blocking=True, blocking_timeout=2.0),
|
||||
call(blocking=True, blocking_timeout=1.5),
|
||||
]
|
||||
assert redis_client.lock.return_value.release.call_count == 2
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == new_cache_key
|
||||
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
||||
|
||||
def test_fetch_plugin_model_providers_falls_back_when_generation_retries_exhaust_wait_budget(self) -> None:
|
||||
"""Generation retry loops share one request-local lock wait deadline before direct fetch fallback."""
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
stale_cache_key = _provider_cache_key("tenant-1", 0)
|
||||
new_cache_key = _provider_cache_key("tenant-1", 1)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", side_effect=[100.0, 100.0, 102.1]),
|
||||
):
|
||||
redis_client.get.side_effect = [None, b"1", b"1", b"1"]
|
||||
redis_client.mget.side_effect = [[None], [None], [None]]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert redis_client.get.call_args_list == [
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
call(generation_key),
|
||||
]
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([stale_cache_key]),
|
||||
call([new_cache_key]),
|
||||
call([new_cache_key]),
|
||||
]
|
||||
redis_client.lock.assert_called_once_with(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
)
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=True, blocking_timeout=2.0)
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == new_cache_key
|
||||
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
||||
|
||||
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_after_store(self) -> None:
|
||||
"""The refresh owner releases only its token after storing provider metadata."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
redis_client.lock.assert_called_once_with(
|
||||
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
|
||||
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
|
||||
sleep=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL,
|
||||
)
|
||||
redis_client.lock.return_value.acquire.assert_called_once_with(
|
||||
blocking=True,
|
||||
blocking_timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT,
|
||||
)
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([cache_key]),
|
||||
call([cache_key]),
|
||||
]
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
redis_client.eval.assert_not_called()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_returns_fresh_result_when_refresh_lock_release_fails(self) -> None:
|
||||
"""Release failures are logged, not allowed to hide a successful daemon refresh."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.lock.return_value.release.side_effect = RedisError("release failed")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([cache_key]),
|
||||
call([cache_key]),
|
||||
]
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_called_once()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_when_fetch_fails(self) -> None:
|
||||
"""Release failures must not hide the daemon failure that happened while owning the lock."""
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.lock.return_value.release.side_effect = RedisError("release failed")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.side_effect = RuntimeError("daemon failed")
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
with pytest.raises(RuntimeError, match="daemon failed"):
|
||||
PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([cache_key]),
|
||||
call([cache_key]),
|
||||
]
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_not_called()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
|
||||
def test_fetch_plugin_model_providers_falls_back_when_refresh_lock_acquire_fails(self) -> None:
|
||||
"""Redis acquire failures degrade to a direct daemon fetch instead of hiding provider data."""
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.monotonic", return_value=100.0),
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.lock.return_value.acquire.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
redis_client.lock.return_value.acquire.assert_called_once()
|
||||
redis_client.lock.return_value.release.assert_not_called()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_skips_wait_when_refresh_lock_fails(self) -> None:
|
||||
"""Lock API failures should fall back directly instead of adding timeout latency."""
|
||||
with (
|
||||
@ -330,7 +504,7 @@ class TestPluginModelProviderCache:
|
||||
patch(f"{MODULE}.time.sleep") as sleep,
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.mget.return_value = [None]
|
||||
redis_client.lock.side_effect = RedisError("redis unavailable")
|
||||
redis_client.set.side_effect = AssertionError("raw redis set must not be used for refresh locks")
|
||||
client = Mock()
|
||||
@ -350,8 +524,7 @@ class TestPluginModelProviderCache:
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.lock.return_value.acquire.return_value = True
|
||||
redis_client.mget.return_value = [None]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = []
|
||||
|
||||
@ -362,14 +535,13 @@ class TestPluginModelProviderCache:
|
||||
assert result == ()
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == cache_key
|
||||
redis_client.lock.return_value.release.assert_called_once_with()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
|
||||
def test_fetch_plugin_model_providers_skips_cache_write_when_generation_changes_during_refresh(self) -> None:
|
||||
"""A refresh that started before invalidation must not populate the newer generation cache."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.side_effect = [None, "1"]
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.lock.return_value.acquire.return_value = True
|
||||
redis_client.get.side_effect = [None, None, "1"]
|
||||
redis_client.mget.return_value = [None]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = []
|
||||
|
||||
@ -380,17 +552,16 @@ class TestPluginModelProviderCache:
|
||||
assert result == ()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.setex.assert_not_called()
|
||||
redis_client.lock.return_value.release.assert_called_once_with()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
|
||||
def test_fetch_plugin_model_providers_reuses_cached_empty_provider_list(self) -> None:
|
||||
"""A cached empty list should prevent repeated daemon fetches for tenants without plugin models."""
|
||||
empty_payload = TypeAdapter(list[ProviderEntity]).dump_json([]).decode("utf-8")
|
||||
empty_payload = TypeAdapter(list[ProviderEntity]).dump_json([])
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
legacy_cache_key = _provider_cache_key("tenant-1")
|
||||
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [empty_payload, None]
|
||||
redis_client.mget.return_value = [empty_payload]
|
||||
client = Mock()
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
@ -398,7 +569,7 @@ class TestPluginModelProviderCache:
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert result == ()
|
||||
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
|
||||
redis_client.mget.assert_called_once_with([cache_key])
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
|
||||
def test_fetch_plugin_model_providers_creates_default_client_on_cache_miss(self) -> None:
|
||||
@ -408,7 +579,7 @@ class TestPluginModelProviderCache:
|
||||
patch(f"{MODULE}.PluginModelClient") as client_cls,
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
redis_client.mget.return_value = [None, None]
|
||||
redis_client.mget.return_value = [None]
|
||||
client = client_cls.return_value
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
@ -420,35 +591,6 @@ class TestPluginModelProviderCache:
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_reuses_process_local_cache(self) -> None:
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.PluginModelClient") as client_cls,
|
||||
):
|
||||
redis_client.get.side_effect = [None, None, None]
|
||||
redis_client.mget.return_value = [None, None]
|
||||
client = client_cls.return_value
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
first_result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1")
|
||||
redis_client.get.reset_mock()
|
||||
redis_client.mget.reset_mock()
|
||||
redis_client.setex.reset_mock()
|
||||
client.fetch_model_providers.reset_mock()
|
||||
|
||||
second_result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1")
|
||||
|
||||
redis_client.get.assert_called_once_with(generation_key)
|
||||
redis_client.mget.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
assert [provider.provider for provider in second_result] == ["langgenius/openai/openai"]
|
||||
assert second_result[0] == first_result[0]
|
||||
assert second_result[0] is not first_result[0]
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_uses_redis_pipeline(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
pipe = redis_client.pipeline.return_value
|
||||
@ -476,41 +618,29 @@ class TestPluginModelProviderCache:
|
||||
pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1"))
|
||||
pipe.execute.assert_called_once_with()
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_clears_process_local_cache(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
pipe = redis_client.pipeline.return_value
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService._store_in_memory_plugin_model_providers("tenant-1", 0, [_build_provider_entity()])
|
||||
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
||||
|
||||
assert PluginService._plugin_model_providers_memory_cache == {}
|
||||
redis_client.pipeline.assert_called_once_with(transaction=False)
|
||||
pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1"))
|
||||
pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1"))
|
||||
pipe.execute.assert_called_once_with()
|
||||
|
||||
def test_fetch_plugin_model_providers_ignores_stale_process_local_cache_after_generation_bump(self) -> None:
|
||||
def test_fetch_plugin_model_providers_uses_new_generation_cache_after_generation_bump(self) -> None:
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
new_cache_key = _provider_cache_key("tenant-1", 1)
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.side_effect = [b"1", b"1"]
|
||||
redis_client.get.side_effect = [b"1", b"1", b"1"]
|
||||
redis_client.mget.return_value = [None]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService._store_in_memory_plugin_model_providers("tenant-1", 0, [_build_provider_entity()])
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
redis_client.get.assert_has_calls([call(generation_key), call(generation_key)])
|
||||
redis_client.mget.assert_called_once_with([new_cache_key])
|
||||
redis_client.get.assert_has_calls([call(generation_key), call(generation_key), call(generation_key)])
|
||||
assert redis_client.mget.call_args_list == [
|
||||
call([new_cache_key]),
|
||||
call([new_cache_key]),
|
||||
]
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == new_cache_key
|
||||
assert PluginService._plugin_model_providers_memory_cache["tenant-1"][0] == 1
|
||||
redis_client.lock.return_value.acquire.assert_called_once()
|
||||
redis_client.lock.return_value.release.assert_called_once()
|
||||
assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"]
|
||||
|
||||
|
||||
|
||||
@ -1132,26 +1132,21 @@ export type PluginAutoUpgradeSettingsResponseModel = {
|
||||
}
|
||||
|
||||
export type PluginInstallationItemResponse = {
|
||||
alternative_plugin_id: string
|
||||
checksum: string
|
||||
created_at: string
|
||||
declaration: PluginDeclarationResponse
|
||||
deprecated_reason: string
|
||||
declaration: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
endpoints_active: number
|
||||
endpoints_setups: number
|
||||
id: string
|
||||
installation_id: string
|
||||
latest_unique_identifier: string
|
||||
latest_version: string
|
||||
meta: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
name: string
|
||||
plugin_id: string
|
||||
plugin_unique_identifier: string
|
||||
runtime_type: string
|
||||
source: PluginInstallationSource
|
||||
status: 'active' | 'deleted'
|
||||
tenant_id: string
|
||||
updated_at: string
|
||||
version: string
|
||||
@ -1161,7 +1156,7 @@ export type LatestPluginCache = {
|
||||
alternative_plugin_id: string
|
||||
deprecated_reason: string
|
||||
plugin_id: string
|
||||
status: 'active' | 'deleted'
|
||||
status: string
|
||||
unique_identifier: string
|
||||
version: string
|
||||
}
|
||||
@ -1484,6 +1479,39 @@ export type StrategySetting = 'disabled' | 'fix_only' | 'latest'
|
||||
|
||||
export type UpgradeMode = 'all' | 'exclude' | 'partial'
|
||||
|
||||
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
|
||||
|
||||
export type CoreToolsEntitiesCommonEntitiesI18nObject = {
|
||||
en_US: string
|
||||
ja_JP?: string | null
|
||||
pt_BR?: string | null
|
||||
zh_Hans?: string | null
|
||||
}
|
||||
|
||||
export type PluginCategoryBuiltinToolResponse = {
|
||||
author: string
|
||||
description: CoreToolsEntitiesCommonEntitiesI18nObject
|
||||
label: CoreToolsEntitiesCommonEntitiesI18nObject
|
||||
labels: Array<string>
|
||||
name: string
|
||||
output_schema: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
parameters?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ToolProviderType
|
||||
= | 'api'
|
||||
| 'app'
|
||||
| 'builtin'
|
||||
| 'dataset-retrieval'
|
||||
| 'mcp'
|
||||
| 'plugin'
|
||||
| 'workflow'
|
||||
|
||||
export type PluginDeclarationResponse = {
|
||||
agent_strategy?: {
|
||||
[key: string]: unknown
|
||||
@ -1524,39 +1552,6 @@ export type PluginDeclarationResponse = {
|
||||
version: string
|
||||
}
|
||||
|
||||
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
|
||||
|
||||
export type CoreToolsEntitiesCommonEntitiesI18nObject = {
|
||||
en_US: string
|
||||
ja_JP?: string | null
|
||||
pt_BR?: string | null
|
||||
zh_Hans?: string | null
|
||||
}
|
||||
|
||||
export type PluginCategoryBuiltinToolResponse = {
|
||||
author: string
|
||||
description: CoreToolsEntitiesCommonEntitiesI18nObject
|
||||
label: CoreToolsEntitiesCommonEntitiesI18nObject
|
||||
labels: Array<string>
|
||||
name: string
|
||||
output_schema: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
parameters?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ToolProviderType
|
||||
= | 'api'
|
||||
| 'app'
|
||||
| 'builtin'
|
||||
| 'dataset-retrieval'
|
||||
| 'mcp'
|
||||
| 'plugin'
|
||||
| 'workflow'
|
||||
|
||||
export type RbacRoleAccount = {
|
||||
account_id: string
|
||||
account_name?: string
|
||||
|
||||
@ -1114,7 +1114,7 @@ export const zLatestPluginCache = z.object({
|
||||
alternative_plugin_id: z.string(),
|
||||
deprecated_reason: z.string(),
|
||||
plugin_id: z.string(),
|
||||
status: z.enum(['active', 'deleted']),
|
||||
status: z.string(),
|
||||
unique_identifier: z.string(),
|
||||
version: z.string(),
|
||||
})
|
||||
@ -1729,6 +1729,33 @@ export const zPluginAutoUpgradeFetchResponse = z.object({
|
||||
*/
|
||||
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
|
||||
|
||||
/**
|
||||
* PluginInstallationItemResponse
|
||||
*/
|
||||
export const zPluginInstallationItemResponse = z.object({
|
||||
checksum: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
declaration: z.record(z.string(), z.unknown()),
|
||||
endpoints_active: z.int(),
|
||||
endpoints_setups: z.int(),
|
||||
id: z.string(),
|
||||
meta: z.record(z.string(), z.unknown()),
|
||||
plugin_id: z.string(),
|
||||
plugin_unique_identifier: z.string(),
|
||||
runtime_type: z.string(),
|
||||
source: zPluginInstallationSource,
|
||||
tenant_id: z.string(),
|
||||
updated_at: z.iso.datetime(),
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstallationsResponse
|
||||
*/
|
||||
export const zPluginInstallationsResponse = z.object({
|
||||
plugins: z.array(zPluginInstallationItemResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* I18nObject
|
||||
*
|
||||
@ -2288,40 +2315,6 @@ export const zPluginDeclarationResponse = z.object({
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstallationItemResponse
|
||||
*/
|
||||
export const zPluginInstallationItemResponse = z.object({
|
||||
alternative_plugin_id: z.string(),
|
||||
checksum: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
declaration: zPluginDeclarationResponse,
|
||||
deprecated_reason: z.string(),
|
||||
endpoints_active: z.int(),
|
||||
endpoints_setups: z.int(),
|
||||
id: z.string(),
|
||||
installation_id: z.string(),
|
||||
latest_unique_identifier: z.string(),
|
||||
latest_version: z.string(),
|
||||
meta: z.record(z.string(), z.unknown()),
|
||||
name: z.string(),
|
||||
plugin_id: z.string(),
|
||||
plugin_unique_identifier: z.string(),
|
||||
runtime_type: z.string(),
|
||||
source: zPluginInstallationSource,
|
||||
status: z.enum(['active', 'deleted']),
|
||||
tenant_id: z.string(),
|
||||
updated_at: z.iso.datetime(),
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginInstallationsResponse
|
||||
*/
|
||||
export const zPluginInstallationsResponse = z.object({
|
||||
plugins: z.array(zPluginInstallationItemResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginCategoryInstalledPluginResponse
|
||||
*/
|
||||
|
||||
@ -222,7 +222,7 @@ const Apps = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3',
|
||||
'grid shrink-0 grid-cols-1 content-start gap-3 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
)}
|
||||
>
|
||||
{searchFilteredList.map(app => (
|
||||
|
||||
@ -429,19 +429,17 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search before the right aligned actions', () => {
|
||||
it('should render sort filter before search and the snippets link', () => {
|
||||
renderList()
|
||||
|
||||
const creatorsButton = screen.getByRole('button', { name: 'Creators' })
|
||||
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
|
||||
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
|
||||
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
|
||||
|
||||
expect(snippetsLink).toHaveAttribute('href', '/snippets')
|
||||
expect(creatorsButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(sortButton.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
@ -452,17 +450,6 @@ describe('List', () => {
|
||||
expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should lay out app cards with auto-fill grid columns', () => {
|
||||
renderList()
|
||||
|
||||
const grid = screen.getByTestId('app-card-app-1').parentElement
|
||||
|
||||
expect(grid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide starred section when there are no starred apps', () => {
|
||||
renderList()
|
||||
|
||||
@ -535,24 +522,6 @@ describe('List', () => {
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should lay out first empty state placeholder cards with auto-fill grid columns', () => {
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
|
||||
const { container } = renderList()
|
||||
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
|
||||
.find(element => element.className.includes('grid-rows-4'))
|
||||
|
||||
if (!placeholderGrid)
|
||||
throw new Error('Expected first empty state placeholder grid to render')
|
||||
|
||||
expect(placeholderGrid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
'grid-rows-4',
|
||||
)
|
||||
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
|
||||
})
|
||||
|
||||
it('should hide learn dify in first empty state when learn app is disabled', () => {
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
|
||||
@ -716,6 +685,17 @@ describe('List', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove legacy tagIDs from URL while preserving other filters', async () => {
|
||||
renderList('?category=workflow&tagIDs=tag-1;tag-2&keywords=sales')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'/apps?category=workflow&keywords=sales',
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
|
||||
@ -1058,20 +1058,11 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[26px] shrink-0 items-start px-3" />
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 shrink-0 items-center pt-2 pb-3 pl-4 system-xs-regular text-text-tertiary',
|
||||
app.access_mode ? 'pr-9' : 'pr-4',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
|
||||
{app.author_name && (
|
||||
<>
|
||||
<div className="min-w-0 truncate">{app.author_name}</div>
|
||||
<div className="shrink-0">·</div>
|
||||
</>
|
||||
)}
|
||||
<div className="shrink-0">{editTimeText}</div>
|
||||
<div className="truncate">{app.author_name}</div>
|
||||
<div className="shrink-0">·</div>
|
||||
<div className="truncate">{editTimeText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -54,8 +54,8 @@ export function AppListHeaderFilters({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppTypeFilter value={category} onChange={onCategoryChange} />
|
||||
<TagFilter
|
||||
type="app"
|
||||
@ -63,21 +63,20 @@ export function AppListHeaderFilters({
|
||||
onChange={onTagIDsChange}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
showLeadingIcon={false}
|
||||
triggerClassName="min-w-0"
|
||||
/>
|
||||
<CreatorsFilter value={creatorIDs} onChange={onCreatorIDsChange} />
|
||||
<AppSortFilter value={sortBy} onChange={onSortByChange} />
|
||||
<SearchInput
|
||||
className="w-50 max-w-full"
|
||||
className="w-50"
|
||||
value={keywords}
|
||||
onValueChange={onKeywordsChange}
|
||||
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex max-w-full min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
<AppSortFilter value={sortBy} onChange={onSortByChange} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold whitespace-nowrap text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
@ -88,7 +87,7 @@ export function AppListHeaderFilters({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
|
||||
className="gap-0.5 px-2 shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
|
||||
<span className="pl-1">{t('operation.create', { ns: 'common' })}</span>
|
||||
|
||||
@ -45,7 +45,7 @@ export function AppSortFilter({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`${sortByLabel} ${activeOption.text}`}
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg border-none bg-components-input-bg-normal py-1 pr-2.5 pl-2 text-left whitespace-nowrap outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg border-none bg-components-input-bg-normal py-1 pr-2.5 pl-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span className="flex items-center gap-1 p-1 text-[13px] leading-4 whitespace-nowrap">
|
||||
<span className="font-normal text-text-tertiary">{sortByLabel}</span>
|
||||
|
||||
@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
value: AppListCategory
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
export const APP_LIST_GRID_CLASS_NAME = 'grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 px-8 pt-2'
|
||||
export const APP_LIST_GRID_CLASS_NAME = 'grid grid-cols-1 gap-2.5 px-8 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5'
|
||||
|
||||
@ -26,7 +26,7 @@ type CreatorOption = {
|
||||
isYou: boolean
|
||||
}
|
||||
|
||||
const baseChipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
const CreatorsFilter = ({
|
||||
value,
|
||||
|
||||
@ -57,7 +57,7 @@ function FirstEmptyState({
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
<div className="relative min-h-[430px] flex-1 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
||||
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{EMPTY_PLACEHOLDER_CARD_IDS.map(id => (
|
||||
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
||||
))}
|
||||
|
||||
@ -11,6 +11,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { normalizeAppPagination } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -45,6 +46,9 @@ function List({
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { workspacePermissionKeys } = useAppContext()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const { replace } = useRouter()
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
@ -79,6 +83,16 @@ function List({
|
||||
enabled: canCreateApp,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.has('tagIDs'))
|
||||
return
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('tagIDs')
|
||||
const query = params.toString()
|
||||
replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
|
||||
}, [pathname, replace, searchParams])
|
||||
|
||||
const appListQuery = useMemo<AppListQuery>(() => ({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
|
||||
@ -149,8 +149,18 @@ describe('BuiltInPipelineList', () => {
|
||||
|
||||
const { container } = render(<BuiltInPipelineList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2')
|
||||
expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
|
||||
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
|
||||
})
|
||||
|
||||
it('should have responsive grid columns', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<BuiltInPipelineList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -132,8 +132,7 @@ describe('CustomizedList', () => {
|
||||
|
||||
const { container } = render(<CustomizedList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2')
|
||||
expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
|
||||
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -22,7 +22,7 @@ const BuiltInPipelineList = () => {
|
||||
const list = pipelineList?.pipeline_templates || []
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2">
|
||||
<div className="grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<CreateCard />
|
||||
{!isLoading && list.map((pipeline, index) => (
|
||||
<TemplateCard
|
||||
|
||||
@ -13,7 +13,7 @@ const CustomizedList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="pt-2 system-sm-semibold-uppercase text-text-tertiary">{t('templates.customized', { ns: 'datasetPipeline' })}</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2">
|
||||
<div className="grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{list.map((pipeline, index) => (
|
||||
<TemplateCard
|
||||
key={index}
|
||||
|
||||
@ -17,22 +17,6 @@ describe('DatasetFirstEmptyState', () => {
|
||||
expect(pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lays out placeholder cards with auto-fill grid columns', () => {
|
||||
const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
|
||||
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
|
||||
.find(element => element.className.includes('grid-rows-4'))
|
||||
|
||||
if (!placeholderGrid)
|
||||
throw new Error('Expected dataset first empty state placeholder grid to render')
|
||||
|
||||
expect(placeholderGrid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
'grid-rows-4',
|
||||
)
|
||||
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
|
||||
})
|
||||
|
||||
it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => {
|
||||
render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset={false} />)
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ function DatasetFirstEmptyState({
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
<div className="relative min-h-[520px] flex-1 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
||||
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{EMPTY_PLACEHOLDER_CARD_IDS.map(id => (
|
||||
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
||||
))}
|
||||
|
||||
@ -45,7 +45,7 @@ function RecommendationSectionSkeletonBody({
|
||||
</div>
|
||||
<SkeletonRectangle className="size-8 shrink-0 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs">
|
||||
<div className="flex flex-col items-start gap-2 pb-1">
|
||||
@ -70,7 +70,7 @@ function RecommendationSectionSkeletonBody({
|
||||
<SkeletonRectangle className="h-5 w-48 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-4 py-3 shadow-md">
|
||||
<SkeletonRow>
|
||||
@ -110,7 +110,7 @@ function ExploreHeaderSkeletonBody() {
|
||||
|
||||
function ExploreAppListSkeletonBody() {
|
||||
return (
|
||||
<div className="grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8">
|
||||
<div className="grid shrink-0 grid-cols-1 content-start gap-3 px-8 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<ExploreAppCardSkeleton key={index} />
|
||||
))}
|
||||
|
||||
@ -35,7 +35,7 @@ const ContinueWork = ({
|
||||
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 pt-2">
|
||||
<div className="grid grid-cols-1 gap-2.5 pt-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{apps.map(app => (
|
||||
<ContinueWorkItem key={app.id} app={app} />
|
||||
))}
|
||||
|
||||
@ -117,7 +117,7 @@ const LearnDifyContent = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{visibleItems.map(item => (
|
||||
<LearnDifyItem
|
||||
key={item.app_id}
|
||||
|
||||
@ -84,9 +84,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({
|
||||
data: { plugins: [] },
|
||||
}),
|
||||
usePluginAutoUpgradeSettings: () => ({
|
||||
data: {
|
||||
category: 'model',
|
||||
@ -114,40 +111,25 @@ vi.mock('@/app/components/plugins/reference-setting-modal', () => ({
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
const originalWorkspaces = actual.consoleQuery.workspaces
|
||||
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, prop) {
|
||||
if (prop === 'workspaces') {
|
||||
if (prop === 'plugins') {
|
||||
return {
|
||||
...originalWorkspaces,
|
||||
current: {
|
||||
...originalWorkspaces.current,
|
||||
plugin: {
|
||||
...originalWorkspaces.current.plugin,
|
||||
list: {
|
||||
...originalWorkspaces.current.plugin.list,
|
||||
installations: {
|
||||
ids: {
|
||||
post: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
latestVersions: {
|
||||
post: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...originalPlugins,
|
||||
checkInstalled: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['plugins', 'checkInstalled'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
latestVersions: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['plugins', 'latestVersions'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,9 +163,6 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { plugins: [] },
|
||||
}),
|
||||
useCheckInstalled: () => ({
|
||||
data: { plugins: [] },
|
||||
}),
|
||||
usePluginAutoUpgradeSettings: () => ({
|
||||
data: mockReferenceSetting.auto_upgrade
|
||||
? {
|
||||
@ -233,40 +230,25 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
const originalWorkspaces = actual.consoleQuery.workspaces
|
||||
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, prop) {
|
||||
if (prop === 'workspaces') {
|
||||
if (prop === 'plugins') {
|
||||
return {
|
||||
...originalWorkspaces,
|
||||
current: {
|
||||
...originalWorkspaces.current,
|
||||
plugin: {
|
||||
...originalWorkspaces.current.plugin,
|
||||
list: {
|
||||
...originalWorkspaces.current.plugin.list,
|
||||
installations: {
|
||||
ids: {
|
||||
post: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
latestVersions: {
|
||||
post: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...originalPlugins,
|
||||
checkInstalled: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['plugins', 'checkInstalled'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
latestVersions: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['plugins', 'latestVersions'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useMemo } from 'react'
|
||||
@ -14,7 +14,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useCheckInstalled } from '@/service/use-plugins'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import UpdateSettingDialog from '../update-setting-dialog'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
@ -64,10 +64,11 @@ const ModelProviderPage = ({
|
||||
const allPluginIds = useMemo(() => {
|
||||
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
|
||||
}, [providers])
|
||||
const { data: installedPlugins } = useCheckInstalled({
|
||||
pluginIds: allPluginIds,
|
||||
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
|
||||
input: { body: { plugin_ids: allPluginIds } },
|
||||
enabled: allPluginIds.length > 0,
|
||||
})
|
||||
staleTime: 0,
|
||||
}))
|
||||
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
|
||||
const pluginDetailMap = useMemo(() => {
|
||||
const map = new Map<string, PluginDetail>()
|
||||
|
||||
@ -11,17 +11,9 @@ vi.mock('@tanstack/react-query', () => ({
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
plugin: {
|
||||
list: {
|
||||
latestVersions: {
|
||||
post: {
|
||||
queryOptions: vi.fn((options: unknown) => options),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
latestVersions: {
|
||||
queryOptions: vi.fn((options: unknown) => options),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -63,7 +55,7 @@ describe('usePluginsWithLatestVersion', () => {
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({
|
||||
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
|
||||
input: { body: { plugin_ids: [] } },
|
||||
enabled: false,
|
||||
})
|
||||
@ -117,7 +109,7 @@ describe('usePluginsWithLatestVersion', () => {
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions).toHaveBeenCalledWith({
|
||||
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
|
||||
input: { body: { plugin_ids: ['plugin-1'] } },
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
@ -109,7 +109,7 @@ export function usePluginsWithLatestVersion(plugins: PluginDetail[] = EMPTY_PLUG
|
||||
[plugins],
|
||||
)
|
||||
|
||||
const { data: latestVersionData } = useQuery(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({
|
||||
const { data: latestVersionData } = useQuery(consoleQuery.plugins.latestVersions.queryOptions({
|
||||
input: { body: { plugin_ids: marketplacePluginIds } },
|
||||
enabled: !!marketplacePluginIds.length,
|
||||
}))
|
||||
|
||||
@ -450,6 +450,18 @@ export type InstalledPluginCategoryListResponse = {
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type InstalledLatestVersionResponse = {
|
||||
versions: {
|
||||
[plugin_id: string]: {
|
||||
unique_identifier: string
|
||||
version: string
|
||||
status: 'active' | 'deleted'
|
||||
deprecated_reason: string
|
||||
alternative_plugin_id: string
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
export type UninstallPluginResponse = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
@ -270,18 +270,6 @@ describe('SnippetList', () => {
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lays out snippet cards with auto-fill grid columns', () => {
|
||||
renderList()
|
||||
|
||||
const card = screen.getByRole('link', { name: /Sales Snippet/ }).closest('article')
|
||||
const grid = card?.parentElement
|
||||
|
||||
expect(grid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes creator, tag, and search filters to the snippets list query', () => {
|
||||
mockQueryState.tagIDs = ['tag-1', 'tag-2']
|
||||
mockQueryState.keywords = 'sales'
|
||||
|
||||
@ -189,7 +189,7 @@ const SnippetList = () => {
|
||||
</div>
|
||||
</StudioListHeader>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-4 px-8 pt-2',
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-8 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
!hasAnySnippet && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
|
||||
32
web/contract/console/plugins.ts
Normal file
32
web/contract/console/plugins.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { InstalledLatestVersionResponse, PluginDetail } from '@/app/components/plugins/types'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from '../base'
|
||||
|
||||
export const pluginCheckInstalledContract = base
|
||||
.route({
|
||||
path: '/workspaces/current/plugin/list/installations/ids',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: {
|
||||
plugin_ids: string[]
|
||||
}
|
||||
}>())
|
||||
.output(type<{ plugins: PluginDetail[] }>())
|
||||
|
||||
export const pluginLatestVersionsContract = base
|
||||
.route({
|
||||
path: '/workspaces/current/plugin/list/latest-versions',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: {
|
||||
plugin_ids: string[]
|
||||
}
|
||||
}>())
|
||||
.output(type<InstalledLatestVersionResponse>())
|
||||
|
||||
export const pluginsRouterContract = {
|
||||
checkInstalled: pluginCheckInstalledContract,
|
||||
latestVersions: pluginLatestVersionsContract,
|
||||
}
|
||||
@ -48,6 +48,7 @@ import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.
|
||||
import { rbacAccessConfigContract } from './console/access-control'
|
||||
import { exploreRouterContract } from './console/explore'
|
||||
import { modelProvidersRouterContract } from './console/model-providers'
|
||||
import { pluginsRouterContract } from './console/plugins'
|
||||
import { snippetsRouterContract } from './console/snippets'
|
||||
import { triggersRouterContract } from './console/trigger'
|
||||
import { trialAppsRouterContract } from './console/try-app'
|
||||
@ -106,6 +107,7 @@ export const consoleRouterContract = {
|
||||
...communityContract,
|
||||
explore: exploreRouterContract,
|
||||
modelProviders: modelProvidersRouterContract,
|
||||
plugins: pluginsRouterContract,
|
||||
rbacAccessConfig: rbacAccessConfigContract,
|
||||
snippets: snippetsRouterContract,
|
||||
triggers: triggersRouterContract,
|
||||
|
||||
@ -165,22 +165,11 @@ describe('AgentRosterList', () => {
|
||||
|
||||
it('renders the Figma-aligned empty roster overlay', () => {
|
||||
const { container } = renderList([])
|
||||
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
|
||||
.find(element => element.className.includes('grid-rows-4'))
|
||||
|
||||
if (!placeholderGrid)
|
||||
throw new Error('Expected agent roster placeholder grid to render')
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'agentV2.roster.empty' })).toHaveClass('system-sm-regular', 'text-text-tertiary')
|
||||
expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16)
|
||||
expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-robot-2-line')).toHaveClass('size-6', 'text-text-tertiary')
|
||||
expect(placeholderGrid).toHaveClass(
|
||||
'grid',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
'grid-rows-4',
|
||||
)
|
||||
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
|
||||
})
|
||||
|
||||
it('uses the same overlay treatment for empty search results', () => {
|
||||
|
||||
@ -71,7 +71,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) {
|
||||
aria-labelledby="agent-roster-placeholder-title"
|
||||
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
||||
<div className="pointer-events-none absolute inset-0 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{emptyPlaceholderCardIds.map(id => (
|
||||
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
||||
))}
|
||||
@ -262,7 +262,7 @@ export function AgentRosterList({
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<section aria-label={label} className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5" aria-busy={isFetching || undefined}>
|
||||
<section aria-label={label} className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,294px),1fr))] gap-2.5" aria-busy={isFetching || undefined}>
|
||||
{isPending && <AgentRosterSkeleton />}
|
||||
{!isPending && isError && (
|
||||
<AgentRosterPlaceholderState title={t('roster.loadingError')} />
|
||||
|
||||
@ -95,11 +95,6 @@ describe('TagFilter', () => {
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom trigger class names', () => {
|
||||
render(<TagFilter {...defaultProps} triggerClassName="min-w-0" />)
|
||||
expect(screen.getByRole('combobox', { name: i18n.placeholder })).toHaveClass('min-w-0')
|
||||
})
|
||||
|
||||
it('should filter tags by type prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagFilter {...defaultProps} type="knowledge" />)
|
||||
|
||||
@ -19,7 +19,6 @@ type TagFilterProps = {
|
||||
onChange: (v: string[]) => void
|
||||
onOpenTagManagement?: () => void
|
||||
showLeadingIcon?: boolean
|
||||
triggerClassName?: string
|
||||
}
|
||||
export const TagFilter = ({
|
||||
type,
|
||||
@ -27,7 +26,6 @@ export const TagFilter = ({
|
||||
onChange,
|
||||
onOpenTagManagement = () => {},
|
||||
showLeadingIcon = true,
|
||||
triggerClassName,
|
||||
}: TagFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -78,9 +76,8 @@ export const TagFilter = ({
|
||||
aria-label={triggerLabel}
|
||||
icon={false}
|
||||
className={cn(
|
||||
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left whitespace-nowrap select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-components-input-bg-normal',
|
||||
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-components-input-bg-normal',
|
||||
!!value.length && 'pr-6 shadow-xs',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-center gap-1">
|
||||
|
||||
@ -16,6 +16,7 @@ const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRout
|
||||
explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)),
|
||||
modelProviders: () =>
|
||||
import('@/contract/console/model-providers').then(({ modelProvidersRouterContract }) => wrapConsoleContract('modelProviders', modelProvidersRouterContract)),
|
||||
plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)),
|
||||
rbacAccessConfig: () =>
|
||||
import('@/contract/console/access-control').then(({ rbacAccessConfigContract }) => wrapConsoleContract('rbacAccessConfig', rbacAccessConfigContract)),
|
||||
snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
FormOption,
|
||||
@ -15,12 +14,10 @@ import type {
|
||||
InstalledPluginListWithTotalResponse,
|
||||
InstallPackageResponse,
|
||||
InstallStatusResponse,
|
||||
MetaData,
|
||||
PackageDependency,
|
||||
Permissions,
|
||||
Plugin,
|
||||
PluginDeclaration,
|
||||
PluginDetail,
|
||||
PluginInfoFromMarketPlace,
|
||||
PluginsFromMarketplaceByInfoResponse,
|
||||
PluginsFromMarketplaceResponse,
|
||||
@ -41,7 +38,7 @@ import { cloneDeep } from 'es-toolkit/object'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
|
||||
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchModelProviderModelList } from '@/service/common'
|
||||
import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins'
|
||||
@ -59,165 +56,6 @@ type PluginTaskListResponse = {
|
||||
tasks: PluginTask[]
|
||||
}
|
||||
|
||||
const getString = (value: unknown) => {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
const getI18nValue = (value: object | null | undefined, key: string) => {
|
||||
return value ? getString(Object.entries(value).find(([itemKey]) => itemKey === key)?.[1]) : ''
|
||||
}
|
||||
|
||||
const normalizeI18nObject = (value: object | null | undefined, fallback = ''): PluginDeclaration['label'] => {
|
||||
const en = getI18nValue(value, 'en_US') || getI18nValue(value, 'en-US') || fallback
|
||||
const zhHans = getI18nValue(value, 'zh_Hans') || getI18nValue(value, 'zh-Hans') || en
|
||||
const ja = getI18nValue(value, 'ja_JP') || getI18nValue(value, 'ja-JP') || en
|
||||
const ptBr = getI18nValue(value, 'pt_BR') || getI18nValue(value, 'pt-BR') || en
|
||||
|
||||
return {
|
||||
'en-US': en,
|
||||
'zh-Hans': zhHans,
|
||||
'zh-Hant': en,
|
||||
'pt-BR': ptBr,
|
||||
'es-ES': en,
|
||||
'fr-FR': en,
|
||||
'de-DE': en,
|
||||
'ja-JP': ja,
|
||||
'ko-KR': en,
|
||||
'ru-RU': en,
|
||||
'it-IT': en,
|
||||
'th-TH': en,
|
||||
'uk-UA': en,
|
||||
'vi-VN': en,
|
||||
'ro-RO': en,
|
||||
'pl-PL': en,
|
||||
'hi-IN': en,
|
||||
'tr-TR': en,
|
||||
'fa-IR': en,
|
||||
'sl-SI': en,
|
||||
'id-ID': en,
|
||||
'nl-NL': en,
|
||||
'ar-TN': en,
|
||||
'en_US': en,
|
||||
'zh_Hans': zhHans,
|
||||
'ja_JP': ja,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePluginCategory = (category: PluginInstallationItemResponse['declaration']['category']): PluginCategoryEnum => {
|
||||
switch (category) {
|
||||
case PluginCategoryEnum.tool:
|
||||
return PluginCategoryEnum.tool
|
||||
case PluginCategoryEnum.model:
|
||||
return PluginCategoryEnum.model
|
||||
case PluginCategoryEnum.datasource:
|
||||
return PluginCategoryEnum.datasource
|
||||
case PluginCategoryEnum.trigger:
|
||||
return PluginCategoryEnum.trigger
|
||||
case PluginCategoryEnum.agent:
|
||||
return PluginCategoryEnum.agent
|
||||
case PluginCategoryEnum.extension:
|
||||
return PluginCategoryEnum.extension
|
||||
}
|
||||
return PluginCategoryEnum.extension
|
||||
}
|
||||
|
||||
const normalizePluginSource = (source: PluginInstallationItemResponse['source']): PluginSource => {
|
||||
switch (source) {
|
||||
case PluginSource.github:
|
||||
return PluginSource.github
|
||||
case PluginSource.marketplace:
|
||||
return PluginSource.marketplace
|
||||
case PluginSource.local:
|
||||
return PluginSource.local
|
||||
case PluginSource.debugging:
|
||||
return PluginSource.debugging
|
||||
}
|
||||
return PluginSource.marketplace
|
||||
}
|
||||
|
||||
const normalizePluginMeta = (meta: Record<string, unknown>): MetaData => {
|
||||
return {
|
||||
repo: getString(meta.repo),
|
||||
version: getString(meta.version),
|
||||
package: getString(meta.package),
|
||||
}
|
||||
}
|
||||
|
||||
const createEmptyTrigger = (name: string): PluginDeclaration['trigger'] => ({
|
||||
events: [],
|
||||
identity: {
|
||||
author: '',
|
||||
name,
|
||||
label: normalizeI18nObject(undefined, name),
|
||||
description: normalizeI18nObject(undefined),
|
||||
icon: '',
|
||||
tags: [],
|
||||
},
|
||||
subscription_constructor: {
|
||||
credentials_schema: [],
|
||||
oauth_schema: {
|
||||
client_schema: [],
|
||||
credentials_schema: [],
|
||||
},
|
||||
parameters: [],
|
||||
},
|
||||
subscription_schema: [],
|
||||
})
|
||||
|
||||
const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): PluginDeclaration => {
|
||||
const { declaration } = plugin
|
||||
return {
|
||||
plugin_unique_identifier: plugin.plugin_unique_identifier,
|
||||
version: declaration.version,
|
||||
author: declaration.author ?? '',
|
||||
icon: declaration.icon,
|
||||
icon_dark: declaration.icon_dark ?? undefined,
|
||||
name: declaration.name,
|
||||
category: normalizePluginCategory(declaration.category),
|
||||
label: normalizeI18nObject(declaration.label, declaration.name),
|
||||
description: normalizeI18nObject(declaration.description, declaration.name),
|
||||
created_at: declaration.created_at,
|
||||
resource: declaration.resource,
|
||||
plugins: declaration.plugins,
|
||||
verified: declaration.verified ?? false,
|
||||
endpoint: undefined,
|
||||
tool: undefined,
|
||||
datasource: undefined,
|
||||
model: declaration.model,
|
||||
tags: declaration.tags ?? [],
|
||||
agent_strategy: declaration.agent_strategy,
|
||||
meta: {
|
||||
version: getString(declaration.meta.version) || declaration.version,
|
||||
minimum_dify_version: getString(declaration.meta.minimum_dify_version) || undefined,
|
||||
},
|
||||
trigger: createEmptyTrigger(declaration.name),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemResponse): PluginDetail => {
|
||||
return {
|
||||
id: plugin.id,
|
||||
created_at: plugin.created_at,
|
||||
updated_at: plugin.updated_at,
|
||||
name: plugin.name,
|
||||
plugin_id: plugin.plugin_id,
|
||||
plugin_unique_identifier: plugin.plugin_unique_identifier,
|
||||
declaration: normalizePluginDeclaration(plugin),
|
||||
installation_id: plugin.installation_id,
|
||||
tenant_id: plugin.tenant_id,
|
||||
endpoints_setups: plugin.endpoints_setups,
|
||||
endpoints_active: plugin.endpoints_active,
|
||||
version: plugin.version,
|
||||
latest_version: plugin.latest_version,
|
||||
latest_unique_identifier: plugin.latest_unique_identifier,
|
||||
source: normalizePluginSource(plugin.source),
|
||||
meta: normalizePluginMeta(plugin.meta),
|
||||
status: plugin.status,
|
||||
deprecated_reason: plugin.deprecated_reason,
|
||||
alternative_plugin_id: plugin.alternative_plugin_id,
|
||||
}
|
||||
}
|
||||
|
||||
const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running
|
||||
|
||||
const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({
|
||||
@ -276,13 +114,10 @@ export const useCheckInstalled = ({
|
||||
pluginIds: string[]
|
||||
enabled: boolean
|
||||
}) => {
|
||||
return useQuery(consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
|
||||
return useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
|
||||
input: { body: { plugin_ids: pluginIds } },
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
select: response => ({
|
||||
plugins: response.plugins.map(normalizeInstalledPluginDetail),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
@ -290,7 +125,7 @@ export const useInvalidateCheckInstalled = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
|
||||
queryKey: consoleQuery.plugins.checkInstalled.key(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user