Compare commits

..

4 Commits

44 changed files with 638 additions and 775 deletions

View File

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

View File

@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Any
from pydantic import BaseModel, Field, computed_field, model_validator
@ -32,9 +32,7 @@ class MarketplacePluginDeclaration(BaseModel):
latest_package_identifier: str = Field(
..., description="Unique identifier for the latest package release of the plugin"
)
status: Literal["active", "deleted"] = Field(
..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`"
)
status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`")
deprecated_reason: str = Field(
..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)"
)

View File

@ -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]:

View File

@ -18093,7 +18093,7 @@ Enum class for large language model mode.
| alternative_plugin_id | string | | Yes |
| deprecated_reason | string | | Yes |
| plugin_id | string | | Yes |
| status | string, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
| status | string | | Yes |
| unique_identifier | string | | Yes |
| version | string | | Yes |
@ -19588,24 +19588,17 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| alternative_plugin_id | string | | Yes |
| checksum | string | | Yes |
| created_at | dateTime | | Yes |
| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes |
| deprecated_reason | string | | Yes |
| declaration | object | | Yes |
| endpoints_active | integer | | Yes |
| endpoints_setups | integer | | Yes |
| id | string | | Yes |
| installation_id | string | | Yes |
| latest_unique_identifier | string | | Yes |
| latest_version | string | | Yes |
| meta | object | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| runtime_type | string | | Yes |
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
| status | string, <br>**Available values:** "active", "deleted" | *Enum:* `"active"`, `"deleted"` | Yes |
| tenant_id | string | | Yes |
| updated_at | dateTime | | Yes |
| version | string | | Yes |

View File

@ -42,7 +42,6 @@ from controllers.console.workspace.plugin import (
PluginUploadFromGithubApi,
PluginUploadFromPkgApi,
)
from core.plugin.entities.plugin import PluginInstallation
from core.plugin.impl.exc import PluginDaemonClientSideError
from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
@ -479,19 +478,12 @@ class TestPluginListInstallationsFromIdsApi:
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.plugin.PluginService.list_installations_from_ids",
return_value=[PluginInstallation.model_validate(_plugin_category_list_item())],
return_value=[{"id": "p1"}],
),
):
result = method(api, "t1")
assert result["plugins"][0]["id"] == "entity-1"
assert result["plugins"][0]["name"] == "test-plugin"
assert result["plugins"][0]["installation_id"] == "entity-1"
assert result["plugins"][0]["latest_version"] == "1.0.0"
assert result["plugins"][0]["latest_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
assert result["plugins"][0]["status"] == "active"
assert result["plugins"][0]["deprecated_reason"] == ""
assert result["plugins"][0]["alternative_plugin_id"] == ""
assert "plugins" in result
def test_daemon_error(self, app: Flask):
api = PluginListInstallationsFromIdsApi()

View File

@ -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)

View File

@ -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"]

View File

@ -1132,26 +1132,21 @@ export type PluginAutoUpgradeSettingsResponseModel = {
}
export type PluginInstallationItemResponse = {
alternative_plugin_id: string
checksum: string
created_at: string
declaration: PluginDeclarationResponse
deprecated_reason: string
declaration: {
[key: string]: unknown
}
endpoints_active: number
endpoints_setups: number
id: string
installation_id: string
latest_unique_identifier: string
latest_version: string
meta: {
[key: string]: unknown
}
name: string
plugin_id: string
plugin_unique_identifier: string
runtime_type: string
source: PluginInstallationSource
status: 'active' | 'deleted'
tenant_id: string
updated_at: string
version: string
@ -1161,7 +1156,7 @@ export type LatestPluginCache = {
alternative_plugin_id: string
deprecated_reason: string
plugin_id: string
status: 'active' | 'deleted'
status: string
unique_identifier: string
version: string
}
@ -1484,6 +1479,39 @@ export type StrategySetting = 'disabled' | 'fix_only' | 'latest'
export type UpgradeMode = 'all' | 'exclude' | 'partial'
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
export type CoreToolsEntitiesCommonEntitiesI18nObject = {
en_US: string
ja_JP?: string | null
pt_BR?: string | null
zh_Hans?: string | null
}
export type PluginCategoryBuiltinToolResponse = {
author: string
description: CoreToolsEntitiesCommonEntitiesI18nObject
label: CoreToolsEntitiesCommonEntitiesI18nObject
labels: Array<string>
name: string
output_schema: {
[key: string]: unknown
}
parameters?: Array<{
[key: string]: unknown
}> | null
[key: string]: unknown
}
export type ToolProviderType
= | 'api'
| 'app'
| 'builtin'
| 'dataset-retrieval'
| 'mcp'
| 'plugin'
| 'workflow'
export type PluginDeclarationResponse = {
agent_strategy?: {
[key: string]: unknown
@ -1524,39 +1552,6 @@ export type PluginDeclarationResponse = {
version: string
}
export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote'
export type CoreToolsEntitiesCommonEntitiesI18nObject = {
en_US: string
ja_JP?: string | null
pt_BR?: string | null
zh_Hans?: string | null
}
export type PluginCategoryBuiltinToolResponse = {
author: string
description: CoreToolsEntitiesCommonEntitiesI18nObject
label: CoreToolsEntitiesCommonEntitiesI18nObject
labels: Array<string>
name: string
output_schema: {
[key: string]: unknown
}
parameters?: Array<{
[key: string]: unknown
}> | null
[key: string]: unknown
}
export type ToolProviderType
= | 'api'
| 'app'
| 'builtin'
| 'dataset-retrieval'
| 'mcp'
| 'plugin'
| 'workflow'
export type RbacRoleAccount = {
account_id: string
account_name?: string

View File

@ -1114,7 +1114,7 @@ export const zLatestPluginCache = z.object({
alternative_plugin_id: z.string(),
deprecated_reason: z.string(),
plugin_id: z.string(),
status: z.enum(['active', 'deleted']),
status: z.string(),
unique_identifier: z.string(),
version: z.string(),
})
@ -1729,6 +1729,33 @@ export const zPluginAutoUpgradeFetchResponse = z.object({
*/
export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote'])
/**
* PluginInstallationItemResponse
*/
export const zPluginInstallationItemResponse = z.object({
checksum: z.string(),
created_at: z.iso.datetime(),
declaration: z.record(z.string(), z.unknown()),
endpoints_active: z.int(),
endpoints_setups: z.int(),
id: z.string(),
meta: z.record(z.string(), z.unknown()),
plugin_id: z.string(),
plugin_unique_identifier: z.string(),
runtime_type: z.string(),
source: zPluginInstallationSource,
tenant_id: z.string(),
updated_at: z.iso.datetime(),
version: z.string(),
})
/**
* PluginInstallationsResponse
*/
export const zPluginInstallationsResponse = z.object({
plugins: z.array(zPluginInstallationItemResponse),
})
/**
* I18nObject
*
@ -2288,40 +2315,6 @@ export const zPluginDeclarationResponse = z.object({
version: z.string(),
})
/**
* PluginInstallationItemResponse
*/
export const zPluginInstallationItemResponse = z.object({
alternative_plugin_id: z.string(),
checksum: z.string(),
created_at: z.iso.datetime(),
declaration: zPluginDeclarationResponse,
deprecated_reason: z.string(),
endpoints_active: z.int(),
endpoints_setups: z.int(),
id: z.string(),
installation_id: z.string(),
latest_unique_identifier: z.string(),
latest_version: z.string(),
meta: z.record(z.string(), z.unknown()),
name: z.string(),
plugin_id: z.string(),
plugin_unique_identifier: z.string(),
runtime_type: z.string(),
source: zPluginInstallationSource,
status: z.enum(['active', 'deleted']),
tenant_id: z.string(),
updated_at: z.iso.datetime(),
version: z.string(),
})
/**
* PluginInstallationsResponse
*/
export const zPluginInstallationsResponse = z.object({
plugins: z.array(zPluginInstallationItemResponse),
})
/**
* PluginCategoryInstalledPluginResponse
*/

View File

@ -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 => (

View File

@ -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', () => {

View File

@ -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>
</>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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'

View File

@ -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,

View File

@ -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" />
))}

View File

@ -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,

View File

@ -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')
})
})

View File

@ -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')
})
})
})

View File

@ -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

View File

@ -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}

View File

@ -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} />)

View File

@ -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" />
))}

View File

@ -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} />
))}

View File

@ -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} />
))}

View File

@ -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}

View File

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

View File

@ -163,9 +163,6 @@ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useCheckInstalled: () => ({
data: { plugins: [] },
}),
usePluginAutoUpgradeSettings: () => ({
data: mockReferenceSetting.auto_upgrade
? {
@ -233,40 +230,25 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalWorkspaces = actual.consoleQuery.workspaces
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
return {
...actual,
consoleQuery: new Proxy(actual.consoleQuery, {
get(target, prop) {
if (prop === 'workspaces') {
if (prop === 'plugins') {
return {
...originalWorkspaces,
current: {
...originalWorkspaces.current,
plugin: {
...originalWorkspaces.current.plugin,
list: {
...originalWorkspaces.current.plugin.list,
installations: {
ids: {
post: {
queryOptions: () => ({
queryKey: ['workspaces', 'current', 'plugin', 'list', 'installations', 'ids', 'post'],
queryFn: () => new Promise(() => {}),
}),
},
},
},
latestVersions: {
post: {
queryOptions: () => ({
queryKey: ['workspaces', 'current', 'plugin', 'list', 'latestVersions', 'post'],
queryFn: () => new Promise(() => {}),
}),
},
},
},
},
...originalPlugins,
checkInstalled: {
queryOptions: () => ({
queryKey: ['plugins', 'checkInstalled'],
queryFn: () => new Promise(() => {}),
}),
},
latestVersions: {
queryOptions: () => ({
queryKey: ['plugins', 'latestVersions'],
queryFn: () => new Promise(() => {}),
}),
},
}
}

View File

@ -3,7 +3,7 @@ import type {
ModelProvider,
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
@ -14,7 +14,7 @@ import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/us
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useCheckInstalled } from '@/service/use-plugins'
import { consoleQuery } from '@/service/client'
import UpdateSettingDialog from '../update-setting-dialog'
import {
CustomConfigurationStatusEnum,
@ -64,10 +64,11 @@ const ModelProviderPage = ({
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: allPluginIds } },
enabled: allPluginIds.length > 0,
})
staleTime: 0,
}))
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()

View File

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

View File

@ -109,7 +109,7 @@ export function usePluginsWithLatestVersion(plugins: PluginDetail[] = EMPTY_PLUG
[plugins],
)
const { data: latestVersionData } = useQuery(consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions({
const { data: latestVersionData } = useQuery(consoleQuery.plugins.latestVersions.queryOptions({
input: { body: { plugin_ids: marketplacePluginIds } },
enabled: !!marketplacePluginIds.length,
}))

View File

@ -450,6 +450,18 @@ export type InstalledPluginCategoryListResponse = {
has_more: boolean
}
export type InstalledLatestVersionResponse = {
versions: {
[plugin_id: string]: {
unique_identifier: string
version: string
status: 'active' | 'deleted'
deprecated_reason: string
alternative_plugin_id: string
} | null
}
}
export type UninstallPluginResponse = {
success: boolean
}

View File

@ -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'

View File

@ -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',
)}
>

View File

@ -0,0 +1,32 @@
import type { InstalledLatestVersionResponse, PluginDetail } from '@/app/components/plugins/types'
import { type } from '@orpc/contract'
import { base } from '../base'
export const pluginCheckInstalledContract = base
.route({
path: '/workspaces/current/plugin/list/installations/ids',
method: 'POST',
})
.input(type<{
body: {
plugin_ids: string[]
}
}>())
.output(type<{ plugins: PluginDetail[] }>())
export const pluginLatestVersionsContract = base
.route({
path: '/workspaces/current/plugin/list/latest-versions',
method: 'POST',
})
.input(type<{
body: {
plugin_ids: string[]
}
}>())
.output(type<InstalledLatestVersionResponse>())
export const pluginsRouterContract = {
checkInstalled: pluginCheckInstalledContract,
latestVersions: pluginLatestVersionsContract,
}

View File

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

View File

@ -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', () => {

View File

@ -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')} />

View File

@ -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" />)

View File

@ -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">

View File

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

View File

@ -1,4 +1,3 @@
import type { PluginInstallationItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { MutateOptions, QueryClient, QueryOptions } from '@tanstack/react-query'
import type {
FormOption,
@ -15,12 +14,10 @@ import type {
InstalledPluginListWithTotalResponse,
InstallPackageResponse,
InstallStatusResponse,
MetaData,
PackageDependency,
Permissions,
Plugin,
PluginDeclaration,
PluginDetail,
PluginInfoFromMarketPlace,
PluginsFromMarketplaceByInfoResponse,
PluginsFromMarketplaceResponse,
@ -41,7 +38,7 @@ import { cloneDeep } from 'es-toolkit/object'
import { useCallback, useEffect, useRef } from 'react'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import { useAppContext } from '@/context/app-context'
import { fetchModelProviderModelList } from '@/service/common'
import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins'
@ -59,165 +56,6 @@ type PluginTaskListResponse = {
tasks: PluginTask[]
}
const getString = (value: unknown) => {
return typeof value === 'string' ? value : ''
}
const getI18nValue = (value: object | null | undefined, key: string) => {
return value ? getString(Object.entries(value).find(([itemKey]) => itemKey === key)?.[1]) : ''
}
const normalizeI18nObject = (value: object | null | undefined, fallback = ''): PluginDeclaration['label'] => {
const en = getI18nValue(value, 'en_US') || getI18nValue(value, 'en-US') || fallback
const zhHans = getI18nValue(value, 'zh_Hans') || getI18nValue(value, 'zh-Hans') || en
const ja = getI18nValue(value, 'ja_JP') || getI18nValue(value, 'ja-JP') || en
const ptBr = getI18nValue(value, 'pt_BR') || getI18nValue(value, 'pt-BR') || en
return {
'en-US': en,
'zh-Hans': zhHans,
'zh-Hant': en,
'pt-BR': ptBr,
'es-ES': en,
'fr-FR': en,
'de-DE': en,
'ja-JP': ja,
'ko-KR': en,
'ru-RU': en,
'it-IT': en,
'th-TH': en,
'uk-UA': en,
'vi-VN': en,
'ro-RO': en,
'pl-PL': en,
'hi-IN': en,
'tr-TR': en,
'fa-IR': en,
'sl-SI': en,
'id-ID': en,
'nl-NL': en,
'ar-TN': en,
'en_US': en,
'zh_Hans': zhHans,
'ja_JP': ja,
}
}
const normalizePluginCategory = (category: PluginInstallationItemResponse['declaration']['category']): PluginCategoryEnum => {
switch (category) {
case PluginCategoryEnum.tool:
return PluginCategoryEnum.tool
case PluginCategoryEnum.model:
return PluginCategoryEnum.model
case PluginCategoryEnum.datasource:
return PluginCategoryEnum.datasource
case PluginCategoryEnum.trigger:
return PluginCategoryEnum.trigger
case PluginCategoryEnum.agent:
return PluginCategoryEnum.agent
case PluginCategoryEnum.extension:
return PluginCategoryEnum.extension
}
return PluginCategoryEnum.extension
}
const normalizePluginSource = (source: PluginInstallationItemResponse['source']): PluginSource => {
switch (source) {
case PluginSource.github:
return PluginSource.github
case PluginSource.marketplace:
return PluginSource.marketplace
case PluginSource.local:
return PluginSource.local
case PluginSource.debugging:
return PluginSource.debugging
}
return PluginSource.marketplace
}
const normalizePluginMeta = (meta: Record<string, unknown>): MetaData => {
return {
repo: getString(meta.repo),
version: getString(meta.version),
package: getString(meta.package),
}
}
const createEmptyTrigger = (name: string): PluginDeclaration['trigger'] => ({
events: [],
identity: {
author: '',
name,
label: normalizeI18nObject(undefined, name),
description: normalizeI18nObject(undefined),
icon: '',
tags: [],
},
subscription_constructor: {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
},
subscription_schema: [],
})
const normalizePluginDeclaration = (plugin: PluginInstallationItemResponse): PluginDeclaration => {
const { declaration } = plugin
return {
plugin_unique_identifier: plugin.plugin_unique_identifier,
version: declaration.version,
author: declaration.author ?? '',
icon: declaration.icon,
icon_dark: declaration.icon_dark ?? undefined,
name: declaration.name,
category: normalizePluginCategory(declaration.category),
label: normalizeI18nObject(declaration.label, declaration.name),
description: normalizeI18nObject(declaration.description, declaration.name),
created_at: declaration.created_at,
resource: declaration.resource,
plugins: declaration.plugins,
verified: declaration.verified ?? false,
endpoint: undefined,
tool: undefined,
datasource: undefined,
model: declaration.model,
tags: declaration.tags ?? [],
agent_strategy: declaration.agent_strategy,
meta: {
version: getString(declaration.meta.version) || declaration.version,
minimum_dify_version: getString(declaration.meta.minimum_dify_version) || undefined,
},
trigger: createEmptyTrigger(declaration.name),
}
}
const normalizeInstalledPluginDetail = (plugin: PluginInstallationItemResponse): PluginDetail => {
return {
id: plugin.id,
created_at: plugin.created_at,
updated_at: plugin.updated_at,
name: plugin.name,
plugin_id: plugin.plugin_id,
plugin_unique_identifier: plugin.plugin_unique_identifier,
declaration: normalizePluginDeclaration(plugin),
installation_id: plugin.installation_id,
tenant_id: plugin.tenant_id,
endpoints_setups: plugin.endpoints_setups,
endpoints_active: plugin.endpoints_active,
version: plugin.version,
latest_version: plugin.latest_version,
latest_unique_identifier: plugin.latest_unique_identifier,
source: normalizePluginSource(plugin.source),
meta: normalizePluginMeta(plugin.meta),
status: plugin.status,
deprecated_reason: plugin.deprecated_reason,
alternative_plugin_id: plugin.alternative_plugin_id,
}
}
const isUnfinishedPluginTask = (task: PluginTask) => task.status === TaskStatus.pending || task.status === TaskStatus.running
const normalizeStartedPluginTask = (task: PluginTaskStart): PluginTask => ({
@ -276,13 +114,10 @@ export const useCheckInstalled = ({
pluginIds: string[]
enabled: boolean
}) => {
return useQuery(consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
return useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: pluginIds } },
enabled,
staleTime: 0,
select: response => ({
plugins: response.plugins.map(normalizeInstalledPluginDetail),
}),
}))
}
@ -290,7 +125,7 @@ export const useInvalidateCheckInstalled = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.current.plugin.list.installations.ids.post.key(),
queryKey: consoleQuery.plugins.checkInstalled.key(),
})
}
}